Rock, Paper, Scissors
This is an essay in the Rewriting History series. Please read at least Introducing Herodotus and Rewriting History before proceeding.
The challenge
Show the causality features of Thucydides by designing a simulation that "runs itself" for a while after initial user interaction. The simulation is a simple rock/paper/scissors military simulation in the style of Fire Emblem. The player issues commands at the 0th hour of the day, and the enemy responds in kind. The armies of the player and enemy clash hourly as long as daylight and their respective forces hold. While I won't show the line-by-line Erlang code as before, I'll provide the commented Erlang source code for the curious.
A War of Attrition
Since creating a full strategy game was beyond the scope of this exercise, I wanted to focus on two moderately tricky aspects:
- Defining a fluent whose value is a function of its past value
- Triggering events automatically
This essay will explore each in turn. But first, the rules of the system must be described:
- The player and enemy begin with the same number of troops in three categories: Swordsman, Spearman, and Axman.
- The player's action at the beginning of each day is to choose how many of each type of unit to include in his advance for that day. He is limited to 50 units per advance.
- The enemy employs a psychic, but he is a poor strategist; so he'll send at you the worst possible counter to whatever you send at him, provided he has the troops available.
- Once daylight comes (eight hours into the day), the two advances will meet and begin skirmishing.
- The two forces will skirmish hourly until one or both sides drop, or until dusk falls.
- Swords are strong against axes and weak against spears; axes are strong against spears and weak against swords; and spears are strong against swords and weak against axes.
- Combat is resolved "simultaneously" — losses are not sustained until after the hour is over.
- Units preferentially attack the units they're strong against, then their own unit type, then their weakness.
Therefore, the units lost in an attack are both a function of one's own forces and the enemy's at the time of the attack. Furthermore, losses occur simultaneously on both sides. This complexity and interdependency has a significant consequence: There is a danger of mutually recursive fluent definitions. Even if there's no infinite recursion, a trivial query might require massive fluent evaluation, as in this example of querying {A, 2}:
- Fluent A-0 queries {B, 0} and adds to it
- Fluent B-0 returns a default value
- The result is added to: Fluent A-1 queries {B, 1} and adds to it
- Fluent B-1 queries {A, 0} and adds to it
- Fluent A-0 queries {B, 0} and adds to it
- Fluent B-0 returns a default value
- Fluent A-0 queries {B, 0} and adds to it
- Fluent B-1 queries {A, 0} and adds to it
- The two results above are added to: Fluent A-2 queries {B, 2} and adds to it
- Fluent B-2 queries {A, 1} and adds to it
- Fluent A-1 queries {B, 1} and adds to it
- Fluent B-1 queries {A, 0} and adds to it
- Fluent A-0 queries {B, 0} and adds to it
- Fluent B-0 returns a default value
- Fluent A-0 queries {B, 0} and adds to it
- Fluent B-1 queries {A, 0} and adds to it
- Fluent A-1 queries {B, 1} and adds to it
- Fluent B-2 queries {A, 1} and adds to it
While this might seem a problem solvable by some optimization or trick, those solutions are hacks — there's no way to support this generally. Simulation designers must be wary of mutually dependent fluents like these. There are two common solutions to this problem, and the correct one depends on your application:
- Introduce constants
- Do the mutually dependent calculations in a separate History or in a function outside of Herodotus proper. Then, create a new Fluent which, rather than calculating its value dynamically, returns a constant. This is generally only usable if the past is immutable.
- Combine fluents
- Take the two (or more) mutually dependent Fluents and turn them into a single Fluent that returns the items' respective data. One feature that might help is to use the arbitrary arguments feature to include an indicator of which data should be returned. This is not always an applicable approach, but where it is usable it is very powerful.
The latter approach was chosen for the RPS simulation because it is slightly harder to implement, and this simulation was designed to stress Herodotus/Thucydides. All of the armies correspond to a single fluent selector, with losses being calculated for all three types on both sides in a single fluent which is triggered at each skirmish.
Taking the (Battle)Field
As in the other examples, we'll start by looking at nouns. This simulation involves two Generals (the player and the enemy) and Armies, along with Daylight. It's also important to track the time of the last Skirmish. Finally, the armies have locations: in the Reserve, away from the battle; in the Field, at combat with the enemy; and in Transit, on the way there.
The last thing to consider is the set of verbs: advances, withdrawals, and skirmishes. If skirmishes happen hourly, we'll want to track the time of the last skirmish; and if we want to prevent skirmishing during a withdrawal, we should note withdrawals when they happen.
The armies are expressed in a single Fluent, stored in one big list in the following way:
[{General1Name,
[{UnitType1, count}, {UnitType2, count}, ...]
},
{General2Name,
[{UnitType1, count}, ...]
},
...]
So, the fluent that sets up the armies looks like this:
f init_armies:
%We'll express the place nouns as fluent types
type reserve.
%Grab the army definition from the bindings
value Armies.
%Use a special function to combine army values
combine rps:add_armies(Net, Value).
We use rps:add_armies because it handles all kinds of cases where the Net or the Value are undefined, where one or the other is a tuple and not a list, and so on. rps:add_armies is provided by the rps.erl module, which the simulation writer provided alongside the .tsdl file. Thucydides clients (either remote or local) can supply arbitrary extra Erlang modules that are made available to .hql and .tsdl files. It's up to a Thucydides provider to ensure that these are safe to execute.
The rest of the incident used to initialize the armies and the rest of the simulation follows:
f initial_generals:
%"generals" as a fluent type is the list of generals in the fight
type generals.
%capital-G Generals is a constant list defined in the bindings
%of the incident triggering this fluent.
value Generals.
%field and transit are the other two army locations
f empty_places:
type [field, transit].
%Of course erlang funs work in tsdl.
%This makes one empty army for each general in the bindings.
value lists:map(fun(G) ->
{G, [{swords, 0}, {spears, 0}, {axes, 0}]}
end, Generals)
.
%"withdrawing" marks whether the given general is withdrawing.
f none_withdrawing:
type {withdrawing, Generals}.
%"persist persist." can be abbreviated like so:
persist.
value false.
%"last_skirmish" marks the last time the given two generals sparred.
f no_prior_skirmish:
type {last_skirmish, Generals, Generals}.
persist.
%-24 just means "yesterday, before all this started".
value -24.
%daylight is the current brightness of the battlefield
f daylight:
type daylight.
%bring a 24-hour value into the 0 to 1 range
%0 means "pitch black"
%1 means "high noon"
value (12 - abs(12 - (trunc(Now) rem 24))) / 12.0.
i init_rps(Generals, Armies):
%definitions can take lists like these, too!
set [init_armies, initial_generals, empty_places,
none_withdrawing, no_prior_skirmish, daylight].
Triggering this initialization incident will look something like this to a local client (if the Thucydides process is called rps_sim):
Bindings = dict:store(
"Armies",
[{player, [{swords, 30}, {spears, 30}, {axes, 30}]},
{enemy, [{swords, 30}, {spears, 30}, {axes, 30}]}],
dict:store(
"Generals",
[player, enemy],
dict:new()
)
),
thucydides:trigger(init_rps, Now, Bindings, rps_sim)
For a remote client, the same information is passed, but the dictionary specification is a little different.
Advance Wars
In the remainder of this blog post, we'll look at two uses of the "follow" semantic mentioned in the previous post. A "follow" is a causal linkage from zero or more incidents and conditions to one or more incidents. The structure of a follow is:
follow a_follows_b_or_c_after_5_when_d:
These are (optional) "input event archetypes". When these happen, this is triggered.
in [b,c]
Optionally, you can include requirements as in incident archetypes. If these don't hold, it doesn't trigger, and the follow will try repeatedly to check them as time goes on. Of course, there's no need to duplicate incident archetype preconditions. The follow will keep trying until it meets the preconditions.
require d
The follow starts trying to trigger after the "delay" parameter, which defaults to "delay 0."
delay 5
It tries for a number of time units specified by the "duration" parameter, but will only happen once for each triggering incident. If the conditions aren't met by the time (trigger_time + delay + duration), the trigger fails and the output events don't occur. Duration defaults to "duration 0", meaning it will try only once.
duration 10
Out is a list of output incidents.
out [a]
Bindings are seeded based on the input archetype, if any. After this seeding, they're run through the binder functions one at a time. If all the bindings are defined by the time the binder functions are done, the requirements are checked and so on. If there is no input archetype, the binder functions alone are used. A binder function returns a list of {VarName(a string), [PossibleBindings]}. Binder functions are called in-order and each is given all permutations of the possible bindings for the previous binder. Bindings are referred to in the same way as requirements, etc.
bind get_a_bindings.
Here's an inline binding function.
bind get_more_a_bindings:
FavoriteFood = q({{favorite_food, BPerson}, Time}),
[{"FavoriteFood", [FavoriteFood]}]
.
Let's presume that "advance" is an incident archetype with bindings for the General, the number of Swordsmen (NSw), the number of Spearmen (NSp), and the number of Axmen (NAx). If a follow is defined in this way, we can express "the enemy advances as soon as the player advances" like this:
follow enemy_advance_follows_player_advance:
in [advance].
out [advance].
bind new_army:
EnemyNSw = NSp,
EnemyNSp = NAx,
EnemyNAx = NSw,
%"enemy" is hardcoded here, but it could certainly
%be the result of a fluent query or something.
[{"General", [enemy]},
{"OldGeneral", [General]},
{"NSw", [EnemyNSw]},
{"NSp", [EnemyNSp]},
{"NAx", [EnemyNAx]}]
.
require advancer_is_player:
check OldGeneral =:= player.
require enemy_hasnt_advanced:
type [transit, field].
check
lists:all(fun({_, Amt}) ->
Amt =:= 0
end, units(General, _Val))
.
Now, as soon as the client code triggers a player advance, the enemy advance will trigger automatically. This works for instantaneous triggers, but what about delayed triggers? For these, Thucydides's conception of time must come into play.
Thucydides stores the "current time" automatically, and can be asked for what it thinks the current time is. The message "progress_time" can be sent to a Thucydides process remotely or locally. This message takes a time delta and the resolution by which to step towards it ("progress_time_to" takes a destination time). Thucydides does no interpolation; such interpolation could be costly since fluents can contain arbitrary code. During progress_time, various follows might be triggered, and the result of the message is the list of incidents that were triggered during that time. Here's an example:
%The time argument can be left out(it will use the current time)
%and so can the bindings, if they're empty.
thucydides:trigger(my_arc, my_sim),
%The default resolution is 1.0, but any number can be used
thucydides:progress_time(5, my_sim).
A more complicated case of following is our rule for saying that a skirmish happens whenever the two armies meet on the field. We need to be careful here to prevent an unnecessary number of skirmishes. This problem of potential duplicate skirmishes is why we introduced the "last_skirmish" fluent above (which is set to Time whenever a skirmish occurs) and the follow that expresses this case looks like this:
follow skirmish_when_armies_meet:
out [skirmish].
require last_skirmish_yesterday:
type {last_skirmish, [Attacker, Defender]}.
%'div' is integer division. TSDL will automatically
%truncate both arguments. In this case, we want to make sure that the
%previous skirmish was yesterday or earlier.
check (Value div 24) < (Time div 24).
%Follows with no input incidents should probably
%use infinite duration, otherwise they'll stop being checked
%and never start being checked again.
duration infinity.
bind all_general_combinations:
%Here's another invocation of a custom erlang function.
{Atks, Defs} =
rps_lists:unzipped_combinations(q({generals, Time})),
[{"Attacker", Atks}, {"Defender", Defs}]
.
There's more to this simulation, including rules for skirmishes following other skirmishes after an hour, and withdrawals of each side occurring when one or both sides is defeated, or night falls.
Moving On
This ends the Rewriting History series of essays. The switch to Erlang has empowered Herodotus, and the introduction of HQL/TSDL has empowered its users. Many semantic changes have been made: matching is entirely new, follows have been introduced, and simulations now have a concept of the progression of time. Thucydides can be used in whole or in part (with or without follows). Two simulations have been written in Thucydides.
The next series of essays will discuss the development of a small web-based game that will take advantage of Herodotus's unique features. In this game, the user will play as a Seer, using his supernatural powers to help or harm various political bodies by dispatching parties of heroes to fulfill or prevent prophecies, or to preserve or disrupt previous incidents. The strengths of Herodotus will be shown in three ways: player planning will be based on past and possible future events; memories of party and Seer exploits will be held by NPCs; and the events chosen for the future will be a function of the ways that the player has acted and chosen in the past.
Labels: example, herodotus, thucydides
Read more...