Actions at run time
How startAction and continueAction run an action: building and rolling each roll, choices,
branches, proposed changes and outcomes, pausing at checkpoints, and replaying from answers.
Action definitions are in dice-and-actions.md; call shapes are in
../schemas/engine/. PROVISIONAL where marked.
Starting an action
Section titled “Starting an action”startAction input: ruleset, character, action (id, entry?, source?, followUp?), params, situational,
policy, links (the views filling the link slots; engine-core.md),
participants (the other characters taking part, by role; below), answers (answers to
replay, one per pause; Replaying from answers) and, outside the JSON
shape, an optional dice function.
- Find the action.
- A rules action has its qualified ID (
5e-srd-5.1:saving-throw). - An action defined on an item is
<item id>#<action id>. - An action reached through a content type or an item’s
useis listed under the used action’s ID with the copy’sentry; it runs with that copy’sitemandself. - An action of picked or granted content also names that content as
source; it runs with that content’sitemand the owning copy’sself. - An action of content with no owning copy (picked in a rules-level choice, or nested in
such a pick) is named by
sourcealone, with noentry; it runs with that content’sitemand noself. - The action must appear in
computeSheet’sactionsfor this character, with the sameentryandsource; otherwise the result isunknown-action. followUp: the offer the run takes up, anidfrom computeSheet’soffersfor this character, as the offer’sactiongives it; otherwise the result isunknown-follow-up. For an ephemeral offer, the finished run’sproposedChangesend with{ to: self, step: '', change: { type: clearFollowUp, id } }, so applying them deletes the stored entry in the same batch. A run that fails, pauses for good or whose changes are never applied clears nothing. A derived offer needs no clearing: the action’s changes end its condition.
- A rules action has its qualified ID (
- Parameters. Each declared parameter takes the app’s value. A
usethat fixes a parameter wins over the app’s value. Missing required parameters aremissing-param; wrong types arewrong-type.- A field parameter (
type: id, of: field) takes a field ID, qualified or local to the action’s pack. A field outside itswhereset isinvalid-option. Formulas seeparams.<name>as{ id, type, tags, value }: the field view itswheresees (the qualified ID, the field’s type and its tags on the sheet, written and computed,[]if none) and the field’s computed value. - A content parameter with
where(type: id, of: <content type>) takes an item ID, qualified or local to the action’s pack. An ID that isn’t an item of the type, or whose item doesn’t matchwhere(itemthe candidate, the character’s fields,action.itemandaction.selfthe action’s own), isinvalid-option(data:param,option). Formulas seeparams.<name>as the qualified ID.
- A field parameter (
- Situational choices.
situationalmaps qualified situational IDs to values. Missing choices take theirdefault. Formulas seesituational.<id>by local ID. A choice whoseavailableis false must be left at its default (false for a boolean); selecting it issituational-unavailable(data:situational). Availability is checked once, when the action starts, against the character as given; changes the action proposes don’t affect it. - Participants.
participantsmaps the roles the action declares to lists of views ({ id, values }), each taken by the app from that character’s own computed sheet.- A role the action doesn’t declare, or a count outside the role’s
min(default 0) andmax(default unbounded), isinvalid-participants(data:role). A role not given has no participants. - A view
idused twice across the call isduplicate-id(data:id). - Each declared field reads the view’s value with the same field ID, its pack prefix
ignored, as a link’s does (engine-core.md); undeclared values are
ignored. A value of the wrong type, or two values with one field ID, is
invalid-link-value(data:role,id,field,expected): an action has no way to report it and go on, so it fails. - The participant may use any pack set: the engine reads only the view.
participants.<role>is the role’s views in input order; a view isidplus each declared field, nested at the dots, with its default when the view lacks it, else absent. A nested action (anactionstep) has the participants the step passes on (below), else none: its roles are[].
- A role the action doesn’t declare, or a count outside the role’s
- Run the steps in order, as below.
when: falseskips a step. Each step reads the character as given, or withsheet: proposedthe proposed sheet.
The context of a formula in an action:
itemandself: the copy’s views (absent for a rules action run without an entry; content without an entry hasitemand noself); for an applied entry’s action,itemis its source andselfthe entry (idand its state keys);characterandlinks;params;situational;participants.<role>, and in aneachstepparticipant;steps.<id>: results of earlier steps;flags.<name>: set by branches and by picked choice options.
Fields, character, item and self are read from the step’s sheet: the character as given,
or the proposed sheet (below).
Where item and self are rebound to something else (a choice step’s candidates, a formula a
change passes on), the action’s own context is action: action.item and action.self (absent
when the action has none) and action.step, the current step’s { id, tags }, its tags evaluated.
The proposed sheet
Section titled “The proposed sheet”An action proposes changes and never applies them. A step with sheet: proposed
(default given) reads the character as those changes would leave it:
- Building it. Before the step evaluates anything,
whenincluded, the engine takes the character as given and applies to it, in order, every change proposed so far in this call withto: selfand nocharacter: from earlier steps, from nested actions, from a parent action that runs this one through anactionstep, and from earlier picks of the same choice step. They are applied as oneapplyChangesbatch, not strict, with the call’s links; the sheet is then computed with those links. Changes for a role, a target or a link are never applied: they belong to another character. Follow-ups the batch would trigger are neither listed nor run; the proposed character’s stored follow-ups are never read. - What reads it. Every formula and source of the step: fields,
character, the character functions (countCopies,sumFields, …);selfanditem, the action’s own copy as it is on that sheet (absent if a change removed it); a choice step’s content, copies and added options, and thewhere,preferandrecommendover them; an allocate step’s fields, their values andmax; a roll step’s parts and roll effects (the effects active on that sheet).params,situational(decided when the action starts),participants,links,flagsandstepsdon’t change. - Per pick. A choice step with
sheet: proposedbuilds the sheet again before each pick, since each pick proposes its changes at once.picksstill counts the step’s picks. - Errors. A batch that fails (a
chooseon a copy that isn’t there, afinishCreationproposed before the step while issues remain, an adjust whose incoming-change effects would roll dice, since the batch has no dice function) fails the action withproposed-changes-failed(data:step, the step’s path, anderror, the batch’s error with itspath). Only a pack bug reaches it. - Replay. The proposed sheet follows from the inputs and the recorded answers, so a replay
rebuilds it exactly;
statekeeps its shape. An engine may cache the sheet per count of proposed changes.
Nested steps decide for themselves: a branch with sheet: proposed reads it in its cases’
when and flags, and each step in its arms reads its own sheet.
Computed tags
Section titled “Computed tags”Every tags list with { formula } entries (a roll step’s, a roll part’s, an action step’s, a roll
effect’s added item’s, a proposed change’s) is evaluated when its step runs, in order: a literal tag
is itself; a formula giving a string is one tag, and one giving a list of strings is each of them,
in order, in its place (none for an empty list). A tag already in the list isn’t added again. A
computed tag that isn’t declared (by the ruleset’s packs, a tag declaring the tags above it) fails
the action with unknown-tag (data: tag); a value that is neither a string nor a list of strings
with type-mismatch.
Roll steps
Section titled “Roll steps”A roll step is built, then rolled, then classified.
Building the roll
Section titled “Building the roll”-
Parts. Each part whose
whenholds becomes roll items:diceevaluates to a dice value. Each dice term becomes a dice item, and a non-zero flat part becomes a flat item.valuebecomes a flat item.fieldnames a field: a flat item with that field’s value and source.
A part that reads a numeric field (a
fieldpart, or avaluethat is a single field reference: the parts whose source is the field) carries that field’saddinstances that have astackKeyand counted on the sheet, for step 2.Items take the part’s
source(default: the field for a single-fieldvalueorfield; otherwise the item or rules the action belongs to) and the part’stags, with computed tags evaluated. A part withmultiplied: falsemarks its itemsmultiplied: false, itsifEmptydice included. -
Roll effects. The candidates are:
- the character’s active instances whose target is
rollsand whose tags match one of the step’s tags (computed tags evaluated); - the effects of the selected situational choices. A boolean choice is selected when true; an integer or enum choice when it differs from its default.
An instance with
from: selfapplies only to rolls made by its own copy: its source entry is the action’s entry. Each effect’swhenseesroll(item,self,tags) and the action context,participantsandparticipantincluded, withitem,selfand the content’s other roots the effect’s own, as an option effect’s are: the Bracers of Archery’sselfis the bracers’ copy, whichever weapon attacks. The action’s own item, copy and step areaction.item,action.selfandaction.step. Stacking applies, by stack key as in the sheet, with the parts’ carried instances:- For each stack key that some
addroll effect of this roll has (sourcemeaning each source’s own group, as on the sheet), the group is the roll’saddeffects with that key plus every part’s carried instances with that key. It is reduced by the key’s policy, as on the sheet (playerChoosestakes the recommended option, as everywhere but computeSheet). - A roll effect that doesn’t count adds no item, as before.
- A carried instance that doesn’t count any more comes off its part’s item: the item’s value
is lowered by the instance’s value, and the item lists it in
dropped({ source, value }, roll-item.schema.json). Carried instances whose key no roll effect has stay in the field’s value untouched, so a roll without keyed roll effects is built exactly as before.
A carried instance can only lose: the roll’s group holds every instance the sheet’s held, so one the sheet suppressed is suppressed again. The value comes off the field’s final value, so a field whose keyed effects are later multiplied or bounded gets the drop after those; typed-bonus fields are sums, where the two agree.
addadds an item: dice or flat, with the effect’s source and evaluatedtags. Withmultiplied: false, its items, dice and flat, are markedmultiplied: false.rollModifiercollects one modifier; they apply in the order below.multiplymodifiers stack by key, compared byfactor.atLeastandatMost(withouttally) bound the total once it is rolled (below).setwithpartsreplaces parts, before anything above reads them (below).
2a. Replaced parts. For each part, the counted
seteffects whosepartstags match its items’ tags are stacked by key; of those left, the onesettings.competing.recommendnames (defaulthighest, by value) replaces the part’s items with its value’s items, carrying the part’s tags. Their source is the effect, or the field when the value is a single field reference, which the item then reads (it carries that field’s keyed instances, as in step 1). The first new item lists the replaced items, as built, inreplaces. This happens before the carried instances andaddeffects of step 2 are worked out.2b. Die steps. Each dice item from a part moves along d4, d6, d8, d10, d12 by the sum of the counted
stepDiemodifiers that reach it (all, or those whosepartsmatch; stacking by key, compared bysteps), stopping at the ends. Other sizes stay. The item recordssteppedFrom. - the character’s active instances whose target is
-
Pool dice. Each
poolDiceaddscountdice to its part’s first dice item (the part named bypart, else the first dice part). In effect order, each added die records the effect as its facesource. -
Empty pools. A dice part whose first dice item has 0 dice after pool dice is replaced by its
ifEmptydice, if it has one.emptyis then true. -
Advantage and disadvantage. Count the sources of each kind. A
noAdvantagemodifier sets the advantage count to 0, and anoDisadvantagethe disadvantage count. Then applysettings.rollModifiers.advantage:cancel(default): with both, nothing; otherwise one extra die of the present kind.net: the difference gives the kind and the number of extra dice.stack: one die per source. Both kinds at once is the errorconflicting-roll-modifiers.
The extra dice go on the roll’s main dice item, its first dice item. If that item has no keep or drop modifier, it gains keep-better:
keepHighest(orkeepLowestwhen the step saysbetter: low) of its count before the extra dice, reversed for disadvantage. If it already has one, only its count grows. -
Multiply dice: each
multiplyDicemultiplies the count of every dice item except those markedmultiplied: false. -
Reroll, minimum face, explode: each adds its dice modifier (
reroll/rerollOnceatbelowor less,minimum,explode) to every dice item that doesn’t have one of that type. -
Given faces: the first counted
setFaceson an item (the main dice item, or the first dice item of a part itspartsmatch) gives it its first faces, evaluated now; one not on the die isinvalid-face.
Rolling
Section titled “Rolling”Dice items are rolled in order, each by the formula language’s rules (formulas.md), with faces from the checkpoint.
- Given faces: an item’s given faces are its first, before any face from the
checkpoint, and are marked
set: true. The checkpoint asks only for the rest: the need’sdicecounts exclude them, and a roll with nothing left to ask doesn’t pause or record an answer (later dice, a reroll or an explosion, are asked for as usual). The log entry’sfaceslists only supplied faces. - Faces: under an automatic policy, faces come from the dice function. When paused, the app
supplies one face per die (see Checkpoints). A roll with no dice
items is not a checkpoint: no policy applies, it never pauses, it takes and
records no answer, and its log entry has
by: engineand nofaces. - Multiply: each item’s value (a dice item’s rolled value with its sign, a
flat item’s value), except the items marked
multiplied: false, is multiplied by every countedmultiplyfactor in turn, in effect order. A result whose value before was an integer rounds after each multiplication by the effect’srounding, elsesettings.rounding(defaultfloor). Faces are unchanged; the item’svalueis the multiplied value and itsfactorthe product of the factors. Tallies aren’t multiplied. - Result: the roll’s total is the sum of every item’s value (multiplied), a dice item counting
with its term’s sign. A
splitchange sums the same values per tag. - Bounds: the highest counted
atLeastroll effect raises the total, then the lowest countedatMostlowers it. Stacking applies per op. The bounded total istotal, and the band reads it; the items and faces are unchanged. - The step’s result (
steps.<id>):total;faces: every face of every dice item, in roll order, dropped and rerolled included;kept: kept faces;empty;items: the itemized roll;tallies: each roll tally visible from the action’s pack, by name (below);bounds: each bound that changed the total, in order,{ source, op, value }([]if none), andunbounded, the total before them;band: the first band ofbandswhosewhenholds, withrollbound to this result. A set with avalueevaluates it once first, withrollbound to this result, and its bands see it asroll.value;bandValue: that value, when the step’s set has one (the log entry records it too);bandSet: the qualified ID of the band set the step classified by (5e-srd-5.1:attack), when the step hasbandsand a band held. An app names the bandbands.<set>.<band>.namein the set’s pack.
- Roll tallies: after the roll, each declared tally is summed from the roll
effects that target it (
target: { rolls, tally }) and match the roll as other roll effects do: from 0, everyaddin instance order, then the highestatLeast, then the lowestatMost. Stacking applies per tally and op, as for other roll effects. Tallies never changetotal,facesorkept. A tally nothing added to is 0.
Classifying an earlier roll again
Section titled “Classifying an earlier roll again”A roll step with of builds and rolls nothing and is not a checkpoint:
- The roll.
ofis a path relative to the action (the running action’s own prefix added): a roll step of the action, or of an action it ran (check/roll). In aneachstep, when the path’s first step is aneachstep over the same role, it names that step’s run for the current participant (check[1]/roll). A roll that didn’t run (its step was skipped, or isn’t reached yet) leaves the step without a result, as a falsewhendoes. At load, the path’s first step must be a step of the action (unknown-reference, kindstep). - The result is the earlier roll’s
total,faces,kept,empty,items,tallies,boundsandunbounded, withband,bandValueandbandSetworked out afresh by the step’sbandsin the step’s band context (its flags,params,steps,participants, and in aneachstepparticipantand the participant’s flags). Asplitmay read it as it would the earlier roll. - The log gets an entry
{ step, action, kind: dice, by: engine, of, items, total, band?, bandSet?, bandValue?, summary },ofthe earlier roll’s full path; nofaces. Its summary is worked out as for any roll step, before the band.
The roll summary
Section titled “The roll summary”Every roll step has a summary: what the roll is rolled against, worked out
when the roll is built, before any face is rolled, in the step’s band context (the running
action’s flags, params, steps, participants, situational, fields and the rest:
everything but roll). It is output only: replay never reads it, and it never fails the action.
summary: bandSet: mothership-1e:check # the step's band set, qualified; absent when it has none value: <text> # a set with `value`: that formula, resolved bands: # the bands that can still hold, in the set's order - band: success when: roll.total < 34 && roll.total < 90 # the condition, resolved tests: # when it's a conjunction of plain comparisons of the roll - { of: roll.total, compare: lt, value: 34 } - { of: roll.total, compare: lt, value: 90 } flags: { target: 34 } # the running action's flags at the step, as data- Resolving a condition. Each maximal subexpression of a band’s
when(or the set’svalue) that doesn’t readrollis evaluated and replaced by its value as a literal, and the tree is printed back with the standard printer, as a proposed change’s action-time values are (Changes steps): strings quoted, lists as list literals, dice values in dice notation. Literal guards then decide as evaluation would:false && xisfalseandtrue || xistrue;true && x,false || x,x && trueandx || falsearex;c ? a : bwith a literalcis the taken branch;!of a literal is its negation. A subexpression that fails to evaluate, or gives a map (no literal), is left as written. - Which bands. The set’s bands in order, leaving out each whose resolved condition is
false(it can’t hold) and stopping after the first whose resolved condition istrue(no later band can be reached). A set whose first band holds regardless of the roll (arolledband when there’s no DC) is summarized as that one band. - Structured tests. When a band’s resolved condition is
true,testsis[]. When it is a conjunction (&&) of comparisons each between a roll path (roll.total,roll.value,roll.unboundedorroll.kept[<int>]) and a number literal,testslists them in order, the roll path on the left (34 > roll.totalis{ of: roll.total, compare: lt, value: 34 }).compareis a code:lt(<),le(<=),gt(>),ge(>=),eq(==). Any other condition (!=,%,||, arithmetic on the roll, a function of the faces) haswhenonly. - Wording. The engine outputs codes and data, never display text. An app words a band’s
tests with the pack’s optional
bands.<set>.<band>.conditiontext (ICU argumentsvalue, the first test’s value, andvalue1,value2, … for each test in order), else the spec’scompare.<code>text (i18n/en.yaml, argumentvalue) per test. - Flags. The running action’s flags (of the action
needs.actionand the log entry’sactionname) at the step, each as data asoutcome.datareports values (a dice value in dice notation);{}when there are none. Present on every roll, banded or not. Enclosing actions’ flags aren’t repeated: a panic check a save runs has the panic check’s own. - Where it appears. At a dice pause,
needs.summary, for the roll the pause is for (a pause for more dice mid-roll carries the same one); on each roll log entry, a dice-less roll’s included,summary, the same one.
Previewing a roll. No call previews a roll. To show what a roll is against before the player
rolls, an app starts the action with policy: { dice: 'pause' }, reads needs.summary from the
first dice pause and drops the paused result; startAction is pure, so nothing changes. Params,
participants and situational choices count as they would. The preview summarizes the first roll
the run reaches (an earlier roll in a nested action comes first, named by needs.action); a
choice step with no usable recommendation, an allocation or a handoff before it pauses first;
and a target set after the dice (Fate’s opposition, read by a dice-less step) appears only in
that step’s log entry. A pack that wants the target in the preview sets it in a flag before the
roll.
Other steps
Section titled “Other steps”-
choice: see Choice steps.
-
allocate: see Allocate steps.
-
branch: the first case whose
whenholds, elseelse. Itsflagsare evaluated and set, then its steps run. A nested step’s path is<branch id>/<step id>. Witheach, it runs per participant and its flags are per participant (Each steps). -
changes: a change with
forEachis first repeated: its formula is evaluated to a list (type-mismatchotherwise), and the change is resolved once per element, in list order, withxbound to the element; an empty list proposes nothing. Each copy counts towardmaxActionSteps. A change whosewhenis false (for each element, withforEach) is skipped. Each other proposed change is resolved:- formulas are evaluated (
value,by); - a computed field (
field: { formula },setandadjustonly) is evaluated to a string (type-mismatchotherwise) and qualified against the action’s pack, as a literal field is. Forto: selfor a role it must name a field of the ruleset (unknown-field) that is an input (not-input-field), else the action fails (data:field: the string as computed, or fornot-input-fieldthe qualified ID). For a link it must be a qualified ID (unknown-fieldotherwise) and is passed on unchecked, as a literal one is; entry: selfbecomes the action’s entry;entry: { formula }is evaluated to an instance ID (anything but a non-empty string istype-mismatch) and passed on as written, for the actor, a role, a target or a link alike: the character receiving the change checks it as it would a written ID (unknown-id, …).path.entrymay be computed the same way;- an
addItementry object is resolved:itemis evaluated to a content ID and qualified against the action’s pack;quantity,equippedand eachstatevalue are evaluated;templatesare qualified; a pick’soptiongiven as{ formula }is evaluated to a string or number (type-mismatchotherwise), the rest ofchoicespassed on as written. Without anid, the copy gets<item local id with dots as hyphens>-<n>, the smallestn≥ 1 that the character and the action’s earlieraddItemchanges don’t use, so a replay gives the same IDs. Anitemthat names no content, or a template, is the errorunknown-reference(data:id); - an
applyEffecthas itssourceevaluated to a content ID and qualified against the action’s pack, as anaddItem’sitemis (no content, or a template, isunknown-reference, data:id);activeis evaluated to a boolean (type-mismatchotherwise) and eachstatevalue evaluated;idis passed on as written. Withoutid, the action picks none: applyChanges gives the entry its ID on the character receiving it (engine-core.md), so a change for a target needs no knowledge of the target’s file. AtoggleEffect’sactiveis evaluated the same way; - an
equip’sequippedis evaluated to a boolean (type-mismatchotherwise), for the copyentrynames or for every copycopiesmatches; anapplyTemplate’s orremoveTemplate’stemplateis evaluated to a content ID and qualified assourceis, but must name a template (unknown-referenceotherwise, data:id).setExtensioncan’t be proposed: extension data is an app’s; - an
offerFollowUpnames its follow-up: one the content declares (or its content type) whose added option or added changes propose it, else the content defining the action (<item>#<action>), else the rules (a local ID qualified against the action’s pack). It is resolved to{ type: offerFollowUp, followUp, source?, params? }: the qualified follow-up ID (<content>#<id>,<pack>:<type>#<id>,<pack>:<id>), the content’s source as computeSheet names it, andparamsevaluated (one givingnullleft out). It is proposedto: selfwhatever the step’sto. A follow-up the owner doesn’t declare, or a derived one, isunknown-reference(data:followUp); entry: picked, in a choice step’schangesover copies, becomes the picked instance ID;- a
chooseorunchooseresolves itspath:path.entrylikeentry(self,pickedor an instance ID; left out for a rules-level choice) andpath.choiceas written, or, given as a formula, evaluated to a list of strings (type-mismatchotherwise);optionis evaluated; - a change aimed at copies (
copies: { type, where }) has itstypequalified against the action’s pack and is passed on withwhere; itsbyorvalueis evaluated now unless it readsselforitem, in which case it is passed on as a formula for applyChanges to evaluate per copy. A string value evaluated now is passed on as a quoted string literal; - a change aimed at applied entries (
applied: { where, limit? }: aremoveEffect,toggleEffect,setStateoradjustState) is passed on the same way:whereand abyorvaluethat readsselforitemas formulas,limitas written; - a formula that reads
batchis passed on too: abyorvalueundercopiesorapplied, and anapplyEffect’sstatevalue, which is passed on as{ formula }for applyChanges to evaluate withitemthe source’s item, the receiving character’s fields andbatch(engine-core.md); - a formula passed on (
copies.where,applied.where, abyorvaluethat readsself,itemorbatch, and anapplyEffectstate value that readsbatch) may read fields,self,itemandcharacter, and alsosteps,flags,paramsandaction. Those four are substituted now: each maximal name or member path rooted at one of them (index expressions inside the path included) is evaluated in the action and replaced by its value as a literal, andhas(<such path>)bytrueorfalse. The tree is then printed back with the standard printer: strings quoted, lists as list literals, dice values in dice notation. A formula that reads none of them is passed on as written. A map has no literal, so a path to one istype-mismatch. A path that fails (a flag no case set isno-such-key) fails the action like any formula error, unless a substitutedhas()guards it: when a guard that is now a literal (false && …,true || …,false ? … : …,!of one) skips the operand holding the failed path, that operand is dropped with the guard (has(flags.x) ? item.k == flags.x : truebecomestrue), as evaluation would skip it. The returned change holds only names applyChanges knows, so a log shows the value ('arrows'), not the name (flags.ammo); - fields are qualified;
- tags are evaluated.
A
splitadjust becomes one change per tag group; its field is always written out (a computed one is a load error,formula-errorwith causetype-mismatch). The result is appended toproposedChangesas{ to, step, change }, wherestepis the path of thechangesstep (deal,panic/stress); the changes of one split adjust share it.Releasing held changes. A step with
releasefirst proposes changesholdaction steps hold, then its ownchanges, if any. For each step inrelease.steps, in order, each of its held changes in the order held: whenwhereis given it is evaluated withchangebound to the change’schangerecord (asproposedChangeslists it,tags[]when it has none); a change it holds for is released, one it doesn’t stays held. A step that didn’t run, or holds nothing, gives none. Withcombine: true, releasedadjustchanges whosebyis a number and that are equal in everything else (theirto,characterand the rest of the change,tagsin order) are merged: the first keeps its place withbythe sum (decimals kept to six places), the others go. Each released change keeps itsstep.Routing.
toisself(default),target, a declared role, or{ link: <slot> }.- A change for a link carries
to: { link: <qualified slot id> }and, when the slot is filled,character: the linked character’s ID. An empty slot’s changes are proposed withoutcharacter. Fields, copy types andaddItemitems in a change sent to a link are the linked character’s qualified IDs and are passed on as written; its formulas are evaluated as usual, in the actor’s context. - A change for a role (
targetincluded) carriesto: <role>andcharacter, the participant’s viewid: in aneachstep over that role, the current participant; elsewhere, the step’s changes are resolved once and proposed to each participant in turn, all of the step’s changes for the first, then for the next. With no participants they are proposed once withoutcharacter, as before. Their fields and content are resolved in the action’s pack, as forself, except that a field, content ID (anaddItem’sitem, anapplyEffect’ssource) or copy type qualified with a pack the ruleset doesn’t load is passed on as written, as for a link: the participant’s ownapplyChangeschecks it. A local or bare ID still resolves in the actor’s pack, and an unknown ID of a loaded pack is stillunknown-reference. The build checks such references against the named pack when the repo has it. - The app applies each change to that character with
applyChanges, so its own incoming-change effects, absorbing and overflow apply there.
- formulas are evaluated (
-
Changes content adds: a changes step’s
tags(computed entries evaluated when it runs, with the tagsactionsteps running the action add after them, as for roll and choice steps) are matched by content’schangeseffects (target: { steps: [<tag>], from? }, effects.md). After the step’s ownreleaseandchanges, each activechangesinstance whose source is a content item or a template, whose target tags match one of the step’s (a tag matches itself and the tags below it), whosefrom: self(if any) names the action’s own copy, and whosewhenholds, proposes itschanges, in instance order,toits value’sto, else the step’s. Its formulas see what an added option’s see:itemandselfthe content carrying the effect (entry: selfits copy), the action’s own asaction.item,action.selfandaction.step(the step’s evaluated tags), and the action’sparams,situational,steps,flags,participantsand, in aneachstep,participant. They are resolved as the step’s own changes are, at the step’s path (per participant in aneachstep), and held by aholdaction step like any other. A step with nochangesand norelease(onlytags) is a place content adds to; without matching content it proposes nothing. -
action: runs the used action with the evaluated
params, the same character, entry, source and situational choices. Its steps’ log entries are nested under<step id>/. Its outcome issteps.<id>.resultandsteps.<id>.data. Its proposed changes are appended to the parent’s; withhold: truethey are taken off again once it ends and held under the step, after any earlier run’s, until a changes step releases them (above). The proposed sheet is then worked out afresh, without them. A pause holds nothing: a replay holds them again. Withas: participantthe step is a handoff instead. It also has:- Parameters. Each
paramsformula is evaluated in the parent’s context; one givingnullleaves its parameter out, as if the app hadn’t given it (an optional one takes its default or stays absent, a required one ismissing-param). A field parameter given an object with a stringid(a field parameter’s view,params.skillA) takes thatid. A dice value is passed as its canonical dice notation (2d6 + 1), which adiceparameter reads back as dice: a bomb’s damage passes whole to the bomb Strike. A handoff’s and a follow-up’sparamscarry dice the same way. A handoff’s and a follow-up’sparamsleave out anullthe same way. source. Evaluated to a string (type-mismatchotherwise) and qualified against the action’s pack as a content ID (kept as written when nothing matches). The action isuseif it names a rules action, else<source>#<use>(an action the content or its type defines). The step readscomputeSheet’sactionsfor the character on the step’s sheet and takes the listing with that action and thatsource, the one with the parent’sentryfirst; it runs asstartActionwould run that listing, with itsentryandsource. No such listing isunknown-action(data:action,source), so the used action’savailablemust hold. Withoutsource(orentry), the used action runs with the parent’s entry and source, as before, and itsavailableisn’t checked: a helper action only other actions run declaresavailable: false, socomputeSheetnever offers it.entry. Evaluated to a string, an instance ID (type-mismatchotherwise). The step takes the listing ofcomputeSheet’sactions, for the character on the step’s sheet, with the actionuse(as above) and thatentry, and with thatsourcewhen the step gives one too (with neither, the listing has nosource); it runs asstartActionwould run that listing: that copy isselfand its itemitem. No such listing (no such copy, the copy doesn’t list the action, or itsavailableis false) isunknown-action(data:action,entry, andsourcewhen given). Not on a handoff: its used action runs on another character, whose copies the actor doesn’t know. A replay reads the same listing again.participants. Each formula is evaluated to a list of strings (type-mismatchotherwise). Each must be theidof a participant of the running action, in any role (invalid-participants, data:role, the nested role). The nested action’s roles are then built from those views as the app passed them, exactly as in Starting an action step 4: an undeclared role, or a count outside the role’sminandmax, isinvalid-participants; an ID twice in the nested call isduplicate-id; each declared field is read from the view. Roles it doesn’t give are[].tags. Evaluated in the parent’s context and added, after their own and without repeats, to the evaluated tags of every roll step and choice step of the used action and of the actions it runs in turn (those add their owntagsafter these). Everything that reads those steps’ tags sees them: roll effects, situational choices’ effects, a choice step’s added options andaction.step.tags, and policy rules. Allocate steps don’t get them.situational. Maps situational choice IDs (qualified at load against the action’s pack, as fields are; an unknown one isunknown-reference, kindsituational) to formulas, each evaluated in the parent’s context. The used action, and the actions it runs in turn, see the running action’s situational values with these in their place, as if the app had selected them: formulas read them assituational.<id>, and a selected choice’s effects apply to the rolls it is offered for. A value that doesn’t fit the choice’s type (a boolean, an integer, or one of an enum’svalues) iswrong-type(data:target, the qualified ID, andexpected). Availability isn’t checked: the pack says so. Not on a handoff (schema), where the participant’s player answers their own.
- Parameters. Each
-
outcome: evaluates
result(a string) anddata, then finishes the action. Steps after it don’t run. When the action declaresresults, a result that isn’t listed (or, for{ bands: <set> }, isn’t a band of that set) is the errorundeclared-result(data:result), for a nested action too.
An action that ends without an outcome step finishes with outcome: {}.
Each steps
Section titled “Each steps”A roll, choice, changes, action or branch step with each: <role> runs once per participant of the role, in input order:
participantis bound to the current view, andwhenis evaluated for each participant. With no participants the step doesn’t run, andsteps.<id>is absent.- The run for participant
ihas the path<id>[<i>](saves[1]); its checkpoints, nested steps and log entries use it (check[0]/check). Policyrulesmatch it by tags and by that path. steps.<id>is the list of per-participant results in participant order, null for a participant whosewhenwas false. Inside a latereachstep over the same role, it is the current participant’s result instead, absent when that participant was skipped. Inside the step’s own run for participanti,steps.<id>is the list of the results of runs0toi - 1, null wherewhenskipped one, and absent in the first run. Asplitin such a step reads the current participant’s roll.- A
branchstep witheachpicks its case per participant and keeps itsflagsper participant: inside a latereachstep over the same role, and in the case’s own steps,flags.<name>is the current participant’s value (absent where the branch set none); elsewhere it is the list in participant order, null for a participant the branch’swhenskipped or whose case doesn’t set it. A flag a step sets withouteachlater replaces the list. The case’s steps run within the participant’s run, at<id>[<i>]/<step id>; their results are lists by participant, as aneachstep’s, and they may not have aneachof their own (unknown-roleat load). Cast-spell’s outcome per target is one such flag. outcomesteps don’t takeeach;maxActionStepscounts each run.
Handoffs
Section titled “Handoffs”An action step with each: <role> and as: participant is handed off: the
participant runs the used action on its own character, and the engine doesn’t run it.
- Its
paramsare evaluated in the actor’s context. The used action (use, qualified at load) must exist in the ruleset (unknown-action), and types them: a missing required one ismissing-param, a wrong typewrong-type. - Its
participants, when given, are evaluated as anactionstep’s (above), except that an ID may also be the running character’s ownid(character.id). The used action’s roles check them: an undeclared role, a count outside the role’sminandmax, or an ID that is neither a participant of the running action nor the running character isinvalid-participants(data:role); an ID twice isduplicate-id. - Without a recorded answer, the action pauses with
needs:kind: handoff,step(the indexed path),action(the defining action),participant({ role, id }) andrun: { action, params, participants? },participantsthe evaluated IDs by role (only the roles given). A handoff always pauses: the policy doesn’t apply. - The app runs
run.actionon the participant’s own character, with that character’s packs, situational choices, policy and dice, and with a view of each characterrun.participantsnames (from that character’s own computed sheet, as forstartAction), and answerscontinueActionwith the finishedoutcome({ result?, data? }). Aresultthe used action doesn’t declare isanswer-mismatch. If the participant’s packs lack the action, what the app runs instead is its business. - The step’s result is
steps.<id>.resultand.data, as for a nested action. The handed-off run’s proposed changes are the participant’s, for the app to apply; the actor sees only the outcome.
Choice steps
Section titled “Choice steps”A choice step has one source of options (a fourth, pending choices, is below):
options |
Offers | Option ID |
|---|---|---|
a list [{ id, when?, changes?, to?, flags? }] |
the written options whose when holds, in written order |
the written id |
{ content: { type, where? } } |
the ruleset’s content of type (qualified against the action’s pack), by qualified ID, hidden content left out |
the qualified content ID |
{ copies: { type, where? } } |
the character’s copies of type, inventory then features, each in file order |
the instance ID |
- Added options. A step with
tagsalso offers, after its source’s options, the options content adds: each of the character’s activeoptioneffects (in instance order, as for roll effects) whose target tags match one of the step’s evaluated tags (tag matching), whosefrom: self, if given, names the action’s entry, and whosewhenholds. Its ID is<content id>#<option id>, the content being the item whose copy, pick or grant carries the effect. Of the effects sharing astackKey(source: the same source, as for stacking), only the first offered is; an ID offered already (two copies of one item) is offered once. Itswhenandchangessee the action context withitem,selfand the content’s other roots the effect’s,actionthe action’s own (action.item,action.self,action.step) and, inwhen,picks. Rules-level effects and situational choices add no options. Nothing else changes for a step withouttags. - Candidate formulas. A written option’s
whensees the action context andpicks. A query source’swhereandprefersee the action context withitemthe candidate’s item view,selfthe candidate copy (copies only; over contentselfisn’t bound),picksandaction.picksis how many times this step has already picked the candidate. - Each pick is a checkpoint. Before each pick the options are worked out again. With no
repeatthe step picks once. Withrepeat: { max, min? }(both evaluated once, before the first pick;mindefaults to 0) it picks until it hasmaxpicks, nothing is offered, or#doneis picked. Once it hasminpicks, the reserved option#doneis offered last; picking it ends the step. An option may be picked again while itswhenorwhereholds. - Nothing to offer. A step with nothing to offer on its first pick is skipped: it isn’t a
checkpoint and logs nothing. Without
repeat,steps.<id>is then absent (has(steps.<id>)is false); with it,steps.<id>.pickedis[]. - Recommending.
recommendis evaluated before each pick, withoptionsbound to the IDs the step’s own source offers (#doneand added options left out); it recommends its value if that is one of them, else nothing. Withoutrecommend, acontentorcopiesstep recommends the candidate with the highestprefer(a number; ties go to the earlier candidate). A step with neither recommends nothing, whatever its source and however many candidates it offers: the first candidate is never recommended by default, soneeds.recommendedis absent and an automatic run pauses there. A pack that wants the first says so withrecommend: options[0].#doneand added options are never recommended, so a step offering only added options pauses. - Random picks.
random, a condition, is evaluated before each pick in the same context asrecommend(withoptions). When it holds and the source offers two options or more, the engine draws: one die with as many sides as the source’s offered options (added options and#doneleft out), and recommends optionface − 1in the order offered, instead ofrecommendandprefer. With one option, that option is recommended and nothing is drawn. The draw is a dice checkpoint at the step’s path, before the pick’s own checkpoint: the policy decides it as a roll matched by the step’s evaluated tags (kind: roll), a recorded face is replayed, and a pause isneeds.kind: dicewithdice: [{ count: 1, sides }]anddraw: { among }, the option IDs the faces name. Its log entry iskind: dicewith the one face infacesand the samedraw. A face not on the die isanswer-mismatch. The pick itself is then decided as any other: the player may still pick another option. A creation action shared by a guided and a random start saysrandom: params.random. - Result.
steps.<id>.pickedis the option ID; withrepeat, the list of picked IDs in order,#doneleft out, updated after each pick. Over content or copies withoutrepeat,steps.<id>.itemis the picked content’s item view (the viewitemgives an action of that content), and over copiessteps.<id>.selfis the picked copy’s view. When an added option is picked,.itemand.selfare absent. - Flags. A written or added option may have
flags({ <name>: <formula> }). After each pick (not#done), before any changes, the picked option’s flags are evaluated in order, each withpickedbound and the earlier ones already set, and set asflags.<name>, as a branch case’s are: a written option’s in the action’s context, an added option’s in its own (itemandselfthe effect’s,actionthe action’s own). A flag already set, by a branch or an earlier pick, is replaced; withrepeat, each pick sets its flags in turn. The option’s own changes, the step’s changes and every later step read them. An added option may set only flags its action sets elsewhere, in a branch case or a written option, so every flag stays declared by the action: at load, for each action in the option’s pack scope with a choice step whose written tags match the option’s target, a flag that action doesn’t set isformula-errorwith causeunknown-name, and a value whose type is none of the types the action sets it to istype-mismatch(dynsets accept any type). Packs give such a flag its default in an early branch. - Changes. After each pick (not
#done), the picked option’s ownchanges(a written or added option’s, with itsto, defaultself), then the step’schanges(withto: self), are resolved like achangesstep’s at the choice step’s path and appended toproposedChangesright away. Their formulas seepicked:{ id }, plusitemover content and copies, plusselfover copies (absent for an added option). In a written option’s and the step’s changesitemandselfstay the action’s own; in an added option’s they are the effect’s,entry: selfnames the effect’s copy and fields and content are resolved in the effect’s pack.entry: pickednames the picked copy. Since the action never applies its changes, a budget sees earlier picks throughpicks, or, withsheet: proposed, on the proposed sheet, built again before each pick. - Policy. Each pick is decided by the policy like any choice, matched by the step’s
evaluated
tags. Underautothe engine takes the recommendation; with none, it pauses.
Choice steps over pending choices
Section titled “Choice steps over pending choices”A choice step with options: { pending: { entry? } } asks the choices that
content brings, without naming them. entry, a formula giving an instance ID, limits it to the
choices whose path starts at that copy; without it, every pending choice is asked, rules-level
ones included. Each pick is a checkpoint:
- The step reads the proposed sheet, built again before each pick, as if
it had
sheet: proposed. (sheet: given,repeatandpreferaren’t allowed on this source.) - Of the sheet’s pending choices, in
computeSheet’spendingorder and filtered byentry, the choice of this pick is the first with at least one option thatlistOptionscalls eligible and that isn’t picked yet. A choice whose options are all ineligible is passed over for now: a pick that makes them eligible brings it back. - The options are those, in
listOptionsorder, each named aslistOptionsnames it (a qualified content ID, an inline option ID, or a value as a string), then#done, unless the character on the proposed sheet is being created (creating): every pending choice is below itsmin, so skipping one would leavefinishCreationrefused (creation-incomplete) and the action’s whole batch with it. An answer of#donethere isanswer-mismatch, as for any option not offered. - With no such choice the step ends; on its first pick it is skipped, as a step with nothing to
offer is (
steps.<id>.pickedis[]). - Recommending. The step’s
recommend, if given, withoptionsbound to the options (without#done) andchoiceto the choice’s{ path, owner, id }, plusslotfor a slot of a slotted choice (has(choice.slot)); else the optionlistOptionsmarks recommended (the choice’s ownrecommend); else none, and the step pauses.#doneis never recommended.randomseesoptionsandchoicetoo; when it holds, the pick’s option is drawn among the options, as above, and a draw’s dice pause also carriesneeds.choice. - The pick proposes
{ type: choose, path: <the choice's path>, option: <the option> }toselfat the step’s path, then the step’s ownchanges, whose formulas seepicked:{ id, path }.#doneends the whole step, not just the choice being asked: every choice still pending stays pending, for a later step to ask (“skip the rest”); it is offered only outside creation. To end one choice early, the app answers its picks; a choice withminpicks is no longer asked.
The result is steps.<id>.picked, the picked option IDs in order (#done left out), and
steps.<id>.paths, their choice paths. Policy rules match the step by its tags and path, and
maxActionSteps counts each pick.
Pausing. needs.choice is the choice being asked: path; owner, the key root of the
choice’s owner, qualified (the content ID, <pack>:rules for a rules-level choice, and for a
choice nested in an inline option the owner’s root plus .choices.<choice>.options.<option>);
id; and min and max, its evaluated pick counts as listOptions gives them
(max absent when unbounded), and picked, how many picks it has on the proposed sheet, this
step’s earlier picks of it included, so an app shows “pick 2 of 6”. Formulas (recommend,
random) see choice as { path, owner, id, slot? } only. The app names the question with the choice’s own text
(<owner>.choices.<id>.prompt) and the options as it names listOptions results, so the step
needs no text of its own. A slot of a slotted choice is asked by its own key
(id: class-feat-2) and carries slot: { choice, number }: the app names it with the declared
choice’s prompt, <owner>.choices.<slot.choice>.prompt, filling {slot} with number. A
choice nested in a slot’s inline option has the declared ID in its owner. The log entry records choice, the path, with the option and
index. On replay a recorded pick whose choice is no longer the one asked, or whose option is
no longer offered, is answer-mismatch.
Allocate steps
Section titled “Allocate steps”An allocate step is one checkpoint that hands amounts to several fields.
- The fields. Every integer input field of the ruleset (any loaded pack) whose view
(
item:id,type,tags, its tags on the sheet) matchesfields.where, in the ruleset’s field order (packs in load order, then each pack’s rules files in path order). With none, the step is skipped: no checkpoint, no log entry, andsteps.<id>is absent.fields.whereis evaluated per field when the step runs, withitemthe field’s view and the action’s context, as in a choice step’s content query: the character’s fields,self,character,links,params,situational,participants,steps,flags, andaction(action.item,action.self,action.step), the action’s own item beingaction.itemsinceitemis the field. A query that reads onlyitemmay be resolved once, at load. - What may be given.
- Budget:
budgetis evaluated once. A field with amaxtakes at mostmaxless its input value. For each field,limit, when given, is evaluated withfieldbound to{ id, value }(the qualified ID and the field’s computed value before the step) and bounds it further: the smaller of the two applies. A field with neither has no limit; one whose bound is 0 is still offered, withlimit: 0. - Values: each of
valuesis evaluated once. There are no limits.
- Budget:
- Checking amounts (an answer, or a recommendation). Amounts are whole numbers by qualified
field ID; a field left out gets nothing. They fit when every field is offered and:
- budget: no amount is negative or over its field’s limit, and the total cost, the sum
of
costfor each non-zero amount (fieldandamountbound; default the amount), equals the budget (spend: all, the default) or is at most it (spend: upTo); - values: the amounts, as a list, are the values in some order: each value used exactly once, so no field gets two and none is left over.
- budget: no amount is negative or over its field’s limit, and the total cost, the sum
of
- Recommending.
recommendmaps field IDs (qualified at load) to integer formulas. The amounts it gives are the recommendation when they fit; otherwise there is none. Random amounts: whenrandom, a condition in the action’s context, holds, the recommendation is drawn instead, each draw a die over the fields it may go to, as a choice step’s draw is (a dice checkpoint at the step’s path; a pause givesdraw.among, the field IDs in field order; one candidate is taken without a draw):- values: each value in order goes to a field drawn among the offered fields not given one yet;
- budget: one unit at a time goes to a field drawn among those that can take one more:
the amount stays within its limit, and the unit’s added cost (
costof the new amount less that of the old) is positive and no more than what is left of the budget. It stops when the budget is spent or no field can take another unit. Amounts that don’t fit (some of aspend: allbudget left over) are no recommendation, so the step pauses; the draws stay in the log.
- The checkpoint. A recorded answer is checked again on replay (
answer-mismatchif it no longer fits). Otherwise, underautowith a recommendation, the engine takes it; else the action pauses withneeds.kind: allocateandneeds.allocate:fields,budgetandspend, orvalues, andrecommendedwhen there is a recommendation. Each field is{ id, value, input, limit?, costs? }:value, the computed value before the step, andinput, the input value before the step (saved, else the default), before effects: an amount adds toinput, andvaluelessinputis what effects add (“8 + 7 point buy + 2 racial”);- for a budget only,
limit, the bound that applies, andcosts:costs[n]is what an amount ofncosts (costs[0]is 0), from 0 tolimit, or to the budget when there is no limit, and to 100 at most; it stops before an amount whose cost fails to evaluate.costsees onlyfield,amountand the action’s context, fixed while the step runs, so the total of any amounts is the sum ofcosts[amount]: an app shows what is left ofbudgetwithout the formula. An amount past the array may still be answered and is checked as usual. Nothing is spent before the answer.
- Result.
steps.<id>.amounts: the amounts that do something, in field order (non-zero amounts for a budget; every given value). A budget step proposes oneadjust(bythe amount) per such field, a values step oneset(valuethe value),to: self, at the step’s path, in field order.
Checkpoints and the policy
Section titled “Checkpoints and the policy”Roll, choice and allocate steps are checkpoints (a roll with no dice items isn’t; see
Rolling). The policy decides whether each continues on
its own (auto) or pauses (pause); a handoff is a checkpoint that always pauses:
- The first matching
rulesentry decides. An entry matches bykind, bytags(any of the roll’s or the choice step’s tags, with those anactionstep adds), and bystep(the step’s path). - Otherwise the default applies:
dicefor rolls,choicesfor choices,allocationsfor allocate steps. Each defaults toauto. A rule’skindisroll,choiceorallocate. - A choice or allocate step without a usable recommendation always pauses.
- Each pick of a repeated choice step is its own checkpoint.
What a pause names. needs.step is the step’s path. needs.action is the qualified ID of
the action that defines the step: mothership-1e:panic-check for the panic check a stat check
runs as panic/panic; <item id>#<action id> for an action defined inline on an item; the used
action’s ID for an action reached through use. The app builds the step’s text keys from it
(<actionKey>.steps.<last path segment>.*, <actionKey>.options.<id>.name). An
option added by content is named by its own item instead (<contentId>.options.<option id>.name,
from the ID <content id>#<option id>).
Pausing for dice. needs.dice lists the dice to roll, in item order (count, sides,
zero, tags), needs.items shows the built roll, and needs.summary what it is rolled
against (The roll summary).
- The app answers with one face per die, in that order.
- If rolling needs more dice than that (a reroll, an explosion), the action pauses again, needing one die at a time.
- A wrong number of faces, or a face not on the die, is
answer-mismatch.
Pausing for a choice. needs.options (with #done last once a repeated step has its min
picks, and on every pick over pending choices) and needs.recommended, present only when the
step recommends an option (above); over pending choices,
needs.choice (above). The app answers with one option; an option not offered
(#done before min picks included) is answer-mismatch.
Pausing for an allocation. needs.allocate (above). The app answers with { amounts }; amounts
that don’t fit are answer-mismatch.
Pausing for a handoff. needs.participant and needs.run. The app answers with
{ outcome }.
The log and replay
Section titled “The log and replay”Every checkpoint adds a log entry:
step(the path);action(the defining action, as inneeds.action);kind(dice,choice,allocateorhandoff);by(engineorapp);- the
faces,optionor, for an allocation,amounts; for a random draw on a choice or allocate step,kind: dicewith its one face anddraw: { among }; for a handoff (kind: handoff,by: app), theparticipantand theoutcome; - for each pick of a choice step with
repeator over pending choices,index: 0 for its first pick, then 1, 2, …; the entries share the step’s path; over pending choices, alsochoice, the path of the choice the pick answered; - for rolls, the
items,total,bandandbandSet(as in the step’s result), andtallies: each tally with a non-zero value as{ name, value, sources }, each source{ source, op, value }(anatLeastoratMostis listed only when it changed the value);bounds, when a bound changed the total; andsummary, the roll’s summary, a dice-less roll’s included.
A paused action is its inputs plus its answers so far (state): the ruleset version, the
character, the action, params, situational choices, link views, participants, and every
checkpoint’s faces, option, amounts or handoff outcome,
including those the dice function supplied. continueAction adds the new answer and replays
the action from the start, using recorded answers in order, until it finishes or reaches a
checkpoint with no recorded answer. A repeated choice step consumes its recorded options in
order, one per pick, and handoffs their recorded outcomes. A recorded option that is no longer offered when replayed is
answer-mismatch. Replay is exact because the engine is deterministic given its
answers. That is also what makes a whole run replayable from its inputs and answers (below).
PROVISIONAL internal shape: state is plain JSON, and only its meaning is fixed.
Replaying from answers
Section titled “Replaying from answers”An app that can’t trust a paused state coming back from a client (it embeds the whole
character) keeps only the answers and replays them against its own copy of the character.
startAction’s answers does that in one call:
- The answers. A list, one per pause in order, each exactly what continueAction takes for
that pause:
{ faces }(one face per die ofneeds.dice; a reroll or explosion that pauses again for one die is its own answer),{ option }(each pick of a repeated choice step,#doneincluded),{ amounts }or a handoff’s{ outcome }. - The result is exactly what startAction without
answers, then continueAction with each answer in turn, would return, with the same inputs, startAction’spolicyfor every pause and the same dice function: finished; paused at the first pause the answers don’t reach (needs,stateandlogas the loop leaves them, so continueAction carries it on); or a failure. Omitted or[], startAction runs as without it. - A mismatch. An answer that doesn’t fit its pause, by any of continueAction’s checks
(Checkpoints and the policy; an answer of the wrong kind
included), is
answer-mismatchwithdata.index(its index inanswers, from 0),data.needs(the pause it was given to) anddata.reason. Later answers aren’t read. An answer left over once the action finishes isanswer-mismatchwithdata.indexand noneeds. Any other failure is returned as the loop would return it. - Dice. Only pauses take answers. A checkpoint the policy runs itself asks the dice function, in the order the loop would. To get the same rolls on every replay, pause dice and keep the faces with the answers, or pass a dice function that repeats its faces (a seeded one).
- Not the log. The log lists the checkpoints the engine ran itself (
by: engine) and merges a roll’s pauses into one entry, so it isn’t the answers; apps keep the answers they sent.
An engine may run the loop or replay once with every answer, as long as the result is the same.
Details
Section titled “Details”These follow from the rules above; they are spelled out because engines have to agree:
- Item order: a roll’s items are its parts in order, then
addeffects in the character’s instance order (the rules, features with their picks and grants, inventory with its templates, applied effects), then the selected situational choices’ effects. Dice items roll in this order, so it decides which supplied face goes to which die (the d20, then Bless’s d4). - Item sources:
- A
dicepart takes the action’s owner:{ type: item, id, entry }for a copy’s action,{ type: rules, id }for a rules action. - A
fieldpart, and avaluepart that is a single field name, take the field. - An
addeffect’s item takes the effect’s source:itemortemplatewith the copy’sentry, oritemfor an applied effect such as Bless. - A pool die added by a situational choice records
{ type: situational, id }as its face source.
- A
keptholds the kept faces of every dice item in order, sosteps.attack.kept[0]is the d20 even when Bless adds a d4.- Split: a split adjust emits one change per group, in the order the groups first appear among the roll’s items.
- Paths: a step inside a branch is
<branch>/<step>(hit/confirm); a nested action’s step is<action step>/<its step>(check/check); aneachstep’s run for participantiis<step>[<i>](saves[1]). - Outcomes: an outcome step without
datagives{ result }only. - Log
by: faces supplied by the dice function stayby: enginewhen the action is replayed after a pause.
Errors and limits
Section titled “Errors and limits”- Errors: the result is
{ ok: false, errors }with:unknown-action,missing-param,wrong-type,invalid-option;invalid-participants,duplicate-id,invalid-link-value(participants);undeclared-result;unknown-field,not-input-field(a computed field; a roll part’sfield);answer-mismatch(from replayedanswers, withindexandneeds);proposed-changes-failed(the proposed sheet);conflicting-roll-modifiers;safety-limit-exceeded(maxActionSteps,maxActionDepth, dice limits);- a formula’s error code.
- Other characters: the engine computes one character per call. Another
character taking part is a participant, passed as a view (
participants), and anything that must run on its own sheet is handed off. A lasting other character, such as a crew, is a link slot: its view is an input (links) and its changes goto: { link }. Lists over participants’ outcomes (a group action’s best result) aren’t expressible yet.