Docs: How the engine runs a model

How the engine runs a model

The engine runs a model as a sequence of requests. At each request whoever plays (a person in the player, or an agent) answers with one of the options, or chance picks an outcome. The same engine runs the validator's test games and the player, in your browser. This page describes exactly what the engine does with each part of a model, who sees what, how executability is measured and what the validator reports. The keys themselves are listed in the reference; the expression language is in expressions.

Requests

RequestWhat it carriesThe answer
DecisionThe deciding player, the options, the place (where), and who sees the answerOne of the options
ChanceThe possible outcomes with their probabilities, the place, and who sees the outcomeOne outcome, drawn with those probabilities
EndThe payoff per player (one number each) and the reasonNothing: the game is over
  • Who sees the answer is either everyone, or a list of players (an empty list: nobody).
  • A decision inside a subset choice (choose with min/max) also knows the item behind each option (nothing for done), all options of the choice, and what has been picked so far. Tactics read these as opts, pool and chosen.
  • The reason is the id of the end condition that ended the game, or flow finished without an end condition.
  • An option is shown by its label: a dict shows its label, else its id, else its uid, else ?. Two instances of the same card therefore look the same to a player.

Places (where)

Every request names the place it comes from. Tactics match rules on these names (at:), and the player shows the place of every question.

PlaceWhere it comes from
step <id>Choosing between the legal actions of a step
action <name>An op in the effect of action <name> (also an ability on a content item)
action <name> (cost)An op in the cost of that action
trigger <id>An optional trigger (use / skip), or an op in a trigger's do. A trigger on a content item without its own id uses the item's id
window <name>Priority in window <name>: the playable fast actions plus pass. A window opened with window: true (no name) is window None
content <id>An op in the effect of a content item run by exec

Suffixes: a subset choice adds · n (the n-th element being chosen, from 1); an op in the impact of an emit gets · <event> after the place of the emit; a sample chance adds · <outcome model>. The ops if, for_each, repeat and the effect of an outcome keep the place of their parent. Two more names only appear in error messages: end <node id> (emitting an end_event) and <place> · window (a window op). Decisions inside a window opened by a window op are still window <name>, and the actions played there are action <name>.

Start of a game

  1. Constants, as written in the model.
  2. Variables, in declaration order. init_x may read constants, content, players, n_players, board and own functions and the variables declared before it (not zones, not derived values). init is copied as is (null is None). With scope: player a value that is not a list is repeated for every player; a list is taken as the per-player list.
  3. Zones, in declaration order. init_x gives the contents (for owner: player: a list of lists) and may read every variable. Without init_x a zone is empty (per player: one empty list each). Every dict in the initial contents becomes an instance with its own uid.

The site always runs a model with the values written in it. To try another value, change the constant in the model and check it again.

Instances. create (and a zone's init_x) turn a dict into an instance: a fresh dict with a unique uid. A dict that is model data (from content or constants) is not copied whole: its atomic fields (numbers, text, booleans, null) are copied and the source dict itself is kept under the key origin (so nested fields stay readable as x['origin']['effect']). Writing an attribute of model data is an error (... is model data; make an instance with create first).

Running the flow

The engine walks the flow tree depth first.

NodeWhat happens
sequenceChildren in order
loopBefore every round: if until_x is true, the loop ends. With over_x: the list is evaluated at the start of the round; for each value the variable named in as is set and all children run. Without over_x: the children run once per round. With once: true the loop stops after one round. After 10,000 rounds: ExprError: flow <id>: loop does not end
branchThe children of the first case whose when_x is true; none true: nothing. A case without when_x is NotExecutable
subflowClears results[<ref>], then runs flow flows.<ref> as a scope (see end conditions with scope)
stepSee below

After a node has finished: if it has window, that priority window opens; then, if it has end_event, each listed event is emitted in order, with an end check after each. On a loop both happen once, after the whole loop, not per round.

until_x is only tested at the start of a round, never halfway. over_x is not re-evaluated during a round: a player who is eliminated halfway still gets a turn in that round unless an action condition prevents it.

A step

  1. Evaluate actor_x if present (otherwise there is no actor).
  2. The legal actions are those in do whose pre_x is true, with actor in scope (and self for an ability). An action with pre but no pre_x is NotExecutable.
  3. No legal action: the step is skipped. No end check.
  4. One legal action: it is taken without asking. The player lists it under "automatic", with what happened after the previous question.
  5. Several: a decision for actor at step <id> (no actor: NotExecutable: step <id>: choice between actions without actor_x). With secret: true only the actor sees the choice; with visible_to_x the listed players.
  6. Run the action's cost, then its effect, with local names {actor}.
  7. End check.

Effects and ops

An effect is a list of ops run top to bottom. Local names (actor, names bound by choose, roll, let, look, search, create) live in one dict per action run: cost and effect share it, and so do if branches and repeat bodies. Four places get a copy, so names bound inside are gone afterwards: a for_each body (per item), the impact and the triggers of an emit, the effect of an outcome model, and the effect of an exec. Use a variable to remember something across those boundaries.

Which ops ask something:

OpRequest
chooseA decision when there are 2+ options. One option: taken without asking. No options: ExprError (except a subset choice, which then binds [] when min is 0)
searchA decision among the matches (via choose; secret unless visible_to_x)
rollA chance, uniform over the faces
sampleA chance with the probabilities p_x of the outcome model (must add up to 1 within 1e-6)
draw from a shuffled zoneA chance, uniform over the cards whose position is not known, unless the top card is known or only one unknown card is left
look at a shuffled zoneA chance per looked-at card whose position is not yet known
windowThe decisions of the priority window
emituse / skip decisions for optional triggers, plus whatever the triggers ask

Fields: text or expression

For most fields the engine reads <field>_x (an expression) if present, else <field> if it is a literal that is not text (number, list, bool). Text without _x is a description for people: the engine stops with NotExecutable: <place>: field '<field>' is text without <field>_x. The exception is value on set and let, where text is a literal string (value: open sets 'open'). A required field that is missing gives NotExecutable: <place>: field '<field>' is missing.

The engine reads only pre_x, cond_x, if_x (triggers, modifiers, end conditions), when_x, until_x, over_x, actor_x, who_x, returns_x, first_x, controller_x, change_x, active_x, filter_x, visible_to_x, with_x, of_x (exec) and p_x as expressions; their text twins are documentation. A trigger if, a branch case when, an action pre, an if op cond, exec of and protocol.first without their _x are NotExecutable. A loop's until or over without _x, and an end condition without if_x, are silently ignored: the loop runs forever (until an end condition) or not per player, and the end condition never fires. The validator's gap list ("What the engine still misses") shows all of these.

Deck order

Index 0 of a zone is the top. draw and look take from the top. A zone with shuffled: true has an unknown order except for a known top and bottom:

  • known on top: after look, or after move/create/draw with position: top;
  • known at the bottom: after move/create/draw with position: bottom;
  • everything else is unordered; a chance decides which card it is only when it is drawn or looked at;
  • an item added without position goes between the unknown cards (or at the end if no bottom is known);
  • shuffle (with zone) forgets the known top and bottom. It does not reorder anything: the randomness is in drawing.

Drawing takes the known top first, then a chance over the unknown middle, and only when that is empty the known bottom. From a zone that is not shuffled, draw always takes element 0. draw stops early when the source is empty.

Events and triggers

emit appends the event name to the game's event list and runs:

  1. triggers with timing: when or before;
  2. triggers with timing: instead: the first one that fires replaces the impact;
  3. the impact ops, if no instead-trigger fired;
  4. triggers with timing: after (the default).

Within each phase: mandatory triggers first, then optional ones; within those, model triggers in model order, then triggers on content items. if_x is evaluated just before the trigger would fire, so an earlier trigger can switch off a later one. A content trigger only counts while the item's active_x is true (default: always). An optional trigger asks its controller (controller_x, else the actor of the emitting place; neither: NotExecutable) use or skip at trigger <id>. An instead-trigger that fires adds the event instead:<id>.

Triggers see the local names of the place that emitted, plus event (the event name) and, on a content item, self. A trigger that reads a name another emitting place does not bind fails with a NameError there. Triggers may emit events; more than 50 nested emits is ExprError: ... trigger chain too deep. When the emit is done, temporary modifiers whose until equals this event expire.

A trigger belongs to a content item (the card type), not to one instance: with two copies in play it still fires once per event. Select the instance in if_x through the emitter's local names, for example "card['id'] == self['id']".

Modifiers

modified('stat', base, name=value, ...) starts from base and applies every active modifier for stat: the model's modifiers, those on content items, and temporary ones from modify. Order: by layer (low first, default 0); within a layer, static modifiers in model order and then temporary ones in the order they were added. For each: skip it if the item's active_x or the modifier's if_x is false; else value becomes change_x. Scope: value, the keyword arguments of modified(), self on a content item, and for modify the local names at the time it ran (minus stack and self). A modifier without change_x is NotExecutable; modifiers that call modified() on each other more than 20 deep raise ExprError: modifiers for 'x' refer to each other in a cycle. A modify without until lasts until the next campaign reset.

Priority windows

A window opens after a flow node with window: <name> (or window: true), or through the op {op: window, name: <name>}. Candidates are all actions and content abilities with a speed that the window's allows lists (window: true or a window op without name: every speed). The name must exist in priority.windows.

  1. No candidates at all: the window closes at once, without a request.
  2. first_x decides who gets priority first (evaluated once). Without it: the local actor (for a window op), else player 0. protocol.first as text without first_x is NotExecutable.
  3. The player with priority gets a decision at window <name> between the legal candidates (pre_x true with actor = that player, window = the name, stack = the open stack) and pass. A player without a legal candidate passes automatically, without a request. Eliminated players are skipped.
  4. Playing an action: its cost runs at once (place action <name> (cost)). Then:
    • resolution: immediate (default) or nested: the effect runs at once, followed by an end check. (nested is accepted and currently behaves exactly like immediate.)
    • resolution: lifo_stack: the action goes on the stack ({action, actor, cancelled}), event stack:<name>. The local names of the cost are kept for the effect. Then priority goes to the next player (after_action: next, default) or stays (after_action: same).
  5. Closing (closes): all_pass_in_succession (default) closes when every non-eliminated player has passed in a row; any action resets the count. single_pass_each: a player who passes is out for the rest of the window; it closes when all are out.
  6. With lifo_stack, when the window would close and the stack is not empty, the top item resolves instead: a cancelled item is removed with event cancelled:<name> and no effect and no end check; otherwise event resolves:<name>, its effect runs with the player who played it as actor, the names bound in its cost, and stack without the item, followed by an end check. Then the pass count is reset and the same first player gets priority again: the value first_x gave when the window opened, not a new evaluation. The window closes only when everyone passes with an empty stack.
  7. cancel with target_x marks stack item target (an index into stack, bottom first, so len(stack) - 1 is the top) as cancelled at the moment the op runs: in an effect, when that effect resolves, not when the action is played. Cancelling an item twice is allowed; None does nothing. Without an open window: ExprError. To fix the target when the action is played, bind its index in cost (cost ops see stack); see reference § cancel.

More than 10,000 turns of priority in one window is ExprError: ... window does not close. pass is reserved: an action with a speed may not be called pass.

End conditions

The engine checks end at these moments, and only these:

  • after every step that ran an action;
  • in a window, after every effect that resolves (with immediate, after every played action);
  • after each event of an end_event.

Not after a cost, not after a cancelled stack item, not when a window closes, not at the start or end of a loop round, not when a step is skipped.

The conditions are tested in list order; the first one that fires ends the game (or its subflow) and the rest are not tested. eliminate and continue never stop the scan:

ConditionEffect
no if_xignored
scope: <flow> while that subflow is not runningignored
scope: <flow>, result win/loss/draw, if_x trueresults['<flow>'] is set to the result, event <flow>:<result>, and that subflow ends; the calling flow continues. Checking stops
result: eliminateEvery player in who_x (an index or a list) not yet eliminated is added to eliminated (event eliminated:<p>). Checking continues with the next condition
result: continueignored
other result, if_x falsenext condition
drawthe game ends: 0 for everyone
scorethe game ends with returns_x (one number per player, else ExprError)
win / loss, cooperativethe game ends: everyone +1 (win) or −1 (loss)
win / loss, competitiveneeds who_x (one index): that player gets +1 (win) or −1 (loss), all others the opposite. A list, tuple or set is an ExprError (it would match no player)

A scope equal to the id of the main flow means the whole game. scope only changes the meaning of win, loss and draw; any other result with a scope is only tested while that subflow runs and then acts as without scope. If the main flow runs out without an end condition, the game ends with 0 for everyone and reason flow finished without an end condition.

Engine events

Besides the names you emit, the engine adds these to the event list. The player shows them after each question: emitted events with the triggers they set off, the others under "messages".

EventWhen
<event>every emit and every end_event
instead:<trigger id>an instead-trigger replaced an impact
stack:<action>, resolves:<action>, cancelled:<action>priority window with lifo_stack
<flow>:win, <flow>:loss, <flow>:drawan end condition with scope ended a subflow
eliminated:<player>a player was eliminated
<model>:<outcome id>a sample from an outcome model
exec:<content id>an exec ran a content item
destroy:<label>a destroy
reset:<scope>a campaign reset (default scope label scenario)

Information: who sees what

The engine keeps a list of observations. Each one has a place, a kind, a visibility and a label.

EventWho sees the outcome
DecisionEveryone; with secret only the chooser; with visible_to_x the listed players
rollEveryone, or visible_to_x
sampleEveryone
drawBy destination zone: public everyone, owner the owner, hidden nobody; or visible_to_x
lookThe actor (actor_x or the local actor), or visible_to_x; with no actor, everyone
searchThe searcher sees the whole zone; the choice is secret unless visible_to_x
revealEveryone, or visible_to_x. Zone visibility does not change

What player p knows is everything they observed, in order, with a blank for what they could not see. In the player, "view as" a player shows the table and the questions as that player saw them: hidden zones and other players' hands show card backs, and a hidden outcome says who can see it.

A player that looks ahead (see tactics) must not peek. Before it tries options, everything that player cannot know is redistributed:

  • Drawing from a shuffled zone is already fair: the order is only fixed when a card is drawn.
  • Hidden cards that are already on the table (shadow cards dealt face down) must be marked with drawn_from: <source zone> on their zone. The contents of every such zone that p cannot see are shuffled together with the unknown middle part of the source zone, and dealt back. Counts and identities (uid) stay; only which card each one is changes.
shadow: {kind: set, visibility: hidden, drawn_from: encounter_deck}

A tactic (and a policy) reads the full state, hidden zones included. Not reading what the player cannot know is up to the author.

Executability

A model can be partly executable. The validator (and the diagram) measure it in two layers:

  • rules: every slot the engine needs in flow, actions, triggers, modifiers, the priority protocol, outcome models and end conditions has an _x or a literal.
  • content: every content item with behaviour or card text (effect, triggers, modifiers, abilities, keywords, text, or status: missing) counts as one slot, executable when it has behaviour, its own ops and triggers have no gaps, and its status is not missing. An item with only text is a gap. Keywords count as behaviour when every keyword occurs in quotes in some rule expression ('Sentinel' in c.get('keywords', '')). A call to code of kind content counts as a content gap.

executable is the share over both layers together.

The slots in the rules layer:

WhereSlot is executable when
action with prepre_x exists
chooseoptions, min, max (those present) have _x or a non-text literal
ifcond_x exists
for_each, repeatitems, until likewise
set, letvalue has _x, a non-text literal, or text that is a plain name (open, face_up)
addamount likewise (no text)
set/add with attrof likewise
place, create, move, destroy, revealwhat, and at (place) and n (create) when present
draw, lookn when present
roll, canceldie, target
modify, modifierstat and change_x
callnever (counted as code:<kind>)
saynever (counted as language)
trigger with ifif_x exists
priority.protocol (if any action has a speed)first_x exists, or no first
outcomep_x exists
branch casewhen_x exists
loop with until / overuntil_x / over_x exists
step with several actions, or a choose without actor_xactor_x on the step (or one action whose choose ops all have actor_x)
end condition scoreif_x and returns_x
other end conditionif_x, plus who_x for eliminate and for competitive win/loss without a subflow scope

Below 100%, the validator lists the missing slots under "What the engine still misses", per kind (text, code:<kind>, language, and content:... for gaps inside content items) with their place.

The validator

The validator page shows errors (must be fixed) and warnings (check them: each is either a deliberate choice or a bug). Only a model without errors gets test games.

ErrorsWarnings
mode not competitive/cooperative; persist not campaigndrawn_from source not shuffled; drawn_from on a public zone
duplicate flow id; unknown kind or loop order; branch without cases; subflow or run flow missing; window not in priority.windows; step action missing; loop as not declared in state.varsaction never used by a step (unless it has speed or note); named flow never called
unknown op; zone in from/to/zone not declared; code, outcome model or agent not declared; exec content catalogue missingsay without agent; exec without content or of_x
expression syntax error in any _x, derived value or functionboard never named in an expression; function never called
invalid board; function name taken; function without x; bad params or lettrigger event never emitted (model and content); optional trigger without controller_x
trigger timing unknown; resolution/closes unknown; action with speed named passspeed not allowed by any window; fast action always playable in an opened window (no pre_x, no cost, nothing in its effect an end condition reads)
end scope not a named flow; end_event not a name; competitive win/loss whose who_x is obviously a list (list/tuple/set literal, comprehension, list(), sorted(), set(), tuple())modifier or modify stat never read with modified('stat', ...)
content ability without id or with a name that is already an action; dice face type not in face_types; outcome model without outcomes or an outcome without pcontent item status not verified/uncertain/missing; keyword not used by any rule; ability without speed
open question without questionopen question status not open/assumed/answered
key not allowed by the JSON schema (unknown key 'x' at actions.y; content is not checked)

Before that, the page checks that the text is readable YAML ("The YAML cannot be read") and that it is a model at all, a mapping with a flow ("This is not a model").

The validator does not check that the names in an expression exist; a wrong name shows up as a NameError when the engine evaluates it. The test games find most of these (under "Where games stopped"); follow games in the player to find the rest.

What the validator shows

PartMeaning
Errors and warningsAs above
executableExecutability over rules and content together, with a bar
rulesExecutability of the rules layer
contentExecutability of the content layer: pct (ok/total), or - without content
stepsNumber of step nodes in the flow
opsNumber of ops in actions, triggers, outcome models and content
codeShare of call ops among all ops
open questionsNumber of open_questions
What the engine still missesThe gap list, below 100%
Test games20 games with random choices and random chance: how many finished, the average number of requests, how they ended (per reason), where games stopped (engine errors), and how many did not end within 5000 requests
The flow as a treeThe flow as a text tree: nodes, who acts, actions and their pre, windows, end events

The markers of each step (choice, chance, hidden, window, ...) are shown in the diagram.

Checking and running a model on the site

Everything runs in your browser; nothing is uploaded.

ToolWhat it does
ValidatorPaste a model (and optionally a view). Errors, warnings including unknown keys, executability, the gaps, the flow as a tree, and 20 random test games. "Start from" loads one of the site's examples
PlayerAfter at least one test game finished, "Open in the player" adds your model to the player as an extra game ("Your model: ..."). The player plays one game question by question: the track of the rules, what was asked, what was chosen, what changed, and the table
Diagram"Draw your own model": the rules as a text tree and as a track diagram, with markers per step. See diagram

In the player:

  • Players chooses who plays: one seat by hand ("You play player 0") with the others played by the computer, everyone by hand on one screen, random players, a heuristic from the model's agents (policy_x), or always the first option. The built-in games also offer their tactics (tactics); tactics for your own model cannot be loaded on the site yet.
  • Game (seed) fixes the random numbers: the same seed and the same players give the same game. "Random game" picks another seed.
  • View as shows the game as one player saw it.
  • The table has two tabs: Board (laid out by the view, or automatically without one) and Zones and values.
  • At the end: "End: " and the payoff per player. An engine error stops the game and shows the message.

Playing thousands of games to measure win rates, comparing rule variants or tactics, and changing constants per run are not yet available on the site.

Error messages

The engine stops with one of two kinds of error. NotExecutable: the model lacks something the engine needs (the site shows it as "Not executable: ..."). ExprError: something went wrong while running, such as an expression that fails or a loop that never ends (the site shows it as "Error: ..."). The validator lists them under "Where games stopped" with the number of test games that stopped there; the player shows the message where the game stopped.

MessageMeaningFix
NotExecutable: action X: pre is text without pre_xThe engine needs an expressionAdd pre_x
NotExecutable: <place>: field 'amount' is text without amount_xA text field where the engine needs a valueAdd the _x field, or a literal
NotExecutable: step X: choice between actions without actor_xSeveral legal actions, nobody to chooseactor_x on the step
NotExecutable: <place>: choose without actor_x and without an actor on the stepA choose with 2+ options and no chooseractor_x on the op or the step
NotExecutable: end condition X: a competitive game needs who_xA win without a winnerwho_x on the condition
ExprError: end condition X: who_x gives a list ([0, 1]); a competitive win needs one player index ...who_x gave several players; with a list no player would matchOne index; several winners: result: score with returns_x
ExprError: <place>: window does not closePlayers kept playing fast actions (10,000 priority turns in one window)A pre_x or cost that limits the action (the validator warns: always playable in window)
NotExecutable: trigger X: optional without controller_x or actorNobody to ask use/skipcontroller_x on the trigger
NotExecutable: code 'x' → m:f is not implementedA call to code outside the modelModel it with ops; a model cannot bring its own code
ExprError: error evaluating '...': NameError: name 'x' is not definedName unknown at that placeSee the scope table in expressions; local names do not exist in flow and end
ExprError: zone 'x' not declared / zone 'x' belongs to a player; write x[player]Bad zone pathDeclare the zone, or index it
ExprError: variable 'x' not declaredset/add on an undeclared variableDeclare it under state.vars
ExprError: <place>: move: 'x' is not in <zone>The item is not in fromFollow the game in the player and look at the zone and the value
ExprError: flow X: loop does not enduntil_x never becomes true (10,000 rounds)Set a flag in an effect
ExprError: end condition X: returns_x gives 1 values, expected 2returns_x length differs from num_playersOne number per player
ExprError: outcome model X: probabilities add up to 0.900000, not 1p_x values do not sum to 1Fix the probabilities
End reason flow finished without an end condition (in the test games and the player)The main flow ran outCheck end and when it is checked
N games did not end within 5000 requests: a loop without an end? (validator), "cut off" (player)A loop that never endsCheck the loop conditions and end conditions

Limits

LimitValue
Rounds of one loop, iterations of repeat, turns of priority in one window10,000
Nested emits (trigger chains)50
Nested calls of own functions100
Nested modified() calls20
Requests in one test game of the validator5,000

This page as Markdown