Engine core
How loadRuleset, computeSheet, listOptions and applyChanges behave. The calls’ inputs and
outputs are in engine-interface.md and ../schemas/engine/.
This chapter fixes the algorithms, so every engine computes the same sheet from the same
character. Parts marked PROVISIONAL may change once more games are modeled.
loadRuleset
Section titled “loadRuleset”Input: pack releases in load order (later packs win). Output: a ruleset, or errors.
-
Check the packs.
- Each pack’s
specVersionmust not be newer than the engine’s (spec-version-unsupported). - A pack may appear only once (
duplicate-pack). - Every dependency must be loaded and in range (
missing-dependency). A dependency may appear anywhere in the load order. - Families: a pack not yet expanded (inline, homebrew) has its rules
and content expanded with the families it sees (its own and its dependencies’ declared
values, by name, its own first): family keys, and
fieldsthat name aviewFieldsset. Each family’s values for formulas are its pack’s, then each loaded pack’s additions (a qualified family name) in load order;fieldOfreads an added value’s fields in the pack that added it. Problems areinvalid-family.
- Each pack’s
-
Merge the rules.
- Fields, content types, situational choices, result bands, actions, rules-level choices and
constraints are keyed by qualified ID (
pack:id), so packs never collide. Content type names are qualified too, and a type name resolves like any reference. - Tags are the union of all packs’ tags.
- Settings merge key by key; a later pack overrides an earlier one, and the override is
reported in
conflicts. - Display groups and field hints from later packs override earlier ones with the same ID.
- Creation starts are concatenated in load order into the ruleset’s
creation; a later start with the sameidreplaces the earlier one in its place. Eachactionis qualified (unknown-referencewhen it names no rules action), and every parameter the action requires must be in the start’sparams(missing-param, data:param,creation,pack).randomdefaults to false. - Template fields are keyed by name. A name another pack declared
earlier must have the same
type,ofandvalues, elsetemplate-field-mismatch(data:field,packs: the earlier pack, then the later); the later declaration replaces the earlier. Theiridandsetdefaults are qualified in the declaring pack. - Link slots are keyed by qualified ID. A slot whose
packis loaded too must declare only fields of that pack and its dependencies, each with the same type, elselink-field-mismatch(data:link,field,pack). - A follow-up’s
createneedswhileand noon,becomes,action,changesorto, elseinvalid-create-offer(data:followUp,pack). Itslinkis qualified like a destination’s, and itsstartresolves, once every pack’s starts are merged, to the ruleset’s start with thatid(and, when qualified, thatpack), kept as<pack>:<id>(unknown-referenceotherwise). When the slot’spackis loaded too, the start’s pack must be it or one of its dependencies, elselink-start-mismatch(data:link,start,pack).
- Fields, content types, situational choices, result bands, actions, rules-level choices and
constraints are keyed by qualified ID (
-
Resolve content. Every reference in a pack resolves in that pack, then its dependencies, nearest first (
unknown-referenceif nothing matches). After loading, every content ID, type name, field ID and action ID in the ruleset is qualified. This includes the values ofidandsetitem fields of a content type or offield, and their declared defaults (resolved in the content type’s pack). A template’s values for its base type’s fields and for template fields resolve in the template’s pack.extends: start from the parent item (itself resolved first;circular-extendson a loop,maxExtendsDepthat most). Then apply the item’s own properties: scalars and objects key by key, arrays (tags,effects,choices, …) replaced whole.override: the same merge, applied to the target item in place, in load order. When two packs override the same property of the same item, the later one wins and the pair is reported inconflicts.hides: hidden items stay in the ruleset, markedhidden: true. Queries skip them.- Destinations: a
to: { link }names a slot visible from the pack, qualified at load. Changes sent to a link are the linked character’s, so their fields and copy types must be qualified IDs already (unknown-referenceotherwise) and are not resolved in the ruleset. - Choice tags: each static tag of a choice step’s
tagsand of anoptioneffect’s{ choices }target must be declared by its pack or that pack’s dependencies (a tag also declares the tags above it), elseunknown-tag(data:tag,pack). Other tags are checked by the build only. - Tag members: each tag a declaration’s
membersnames (a field’s, an item field’s, a state key’s, a parameter’s) is checked the same way, and the declaration getstagOptions: the ruleset’s declared tags, with the tags above each, thatmemberstakes, sorted (rules-and-fields.md).
-
Resolve content-only queries. A query whose
wherereads onlyitemis evaluated once against every non-hidden item of its type. Its result (sorted qualified IDs) is stored in the ruleset. Queries that read the character run later. The rules’ and items’standardCopiesqueries are always content-only (awherethat reads anything else is aformula-error, causeunknown-name). Two items they match whose derived instance IDs are the same areduplicate-id(data:id,items). -
Check formulas. Every formula must parse and type-check as in the build (formulas.md), resolving field names in its pack and that pack’s dependencies. Otherwise the error is
formula-error:data.cause: the formula error code (formula-syntax,unknown-name,type-mismatch, …);data.packanddata.formula;path: the document the formula is in (/packs/<i>/rulesor/packs/<i>/content/<j>).
Formulas are checked once the earlier steps succeed. Loops between formulas (
circular-formula) are left to the build and tocomputeSheet.
The ruleset is plain JSON. Formulas may be kept as text and parsed when first used; the result must be the same.
A character’s homebrew
Section titled “A character’s homebrew”A character file may carry its own homebrew (characters.md). Every call
that takes a character (computeSheet, listOptions, applyChanges, startAction,
continueAction for the paused action’s character, upgrade) loads it on top of the ruleset it is
given, then works on the result. A character without homebrew uses the ruleset as given.
- Check what homebrew may not do. A content item with
override, or rules withsettings, ishomebrew-not-allowed(data:property). More content items and rules-level definitions thanmaxHomebrewItemsissafety-limit-exceeded. - Load it as a pack. The section is a pack release with the ID
homebrew, the character’sspecVersion, and every pinned pack the ruleset has as a dependency, in pin order. The loadRuleset steps run for the ruleset’s packs plus this one: merge, resolve references (in the homebrew, then in each pinned pack and its dependencies, in pin order), re-resolve content-only queries so homebrew items join them, and check the homebrew’s formulas. Its formulas written as text count againstmaxHomebrewFormulas. - Errors. If anything fails, the call returns
homebrew-errorand computes nothing: one error per load error, withdata.causethe load code (unknown-reference,formula-error,homebrew-not-allowed,safety-limit-exceeded, …),data.detailthat error’s data, andpathpointing into the character (/homebrew/content/0,/homebrew/rules;/homebrewwhen the load error has no document). computeSheet returns empty values with the errors; the other calls return their failure.
Engines MAY cache the layered ruleset by the homebrew’s content and the pins; the result must
be the same as loading it fresh. The ruleset must therefore keep what is needed to load more on
top: the reference engine keeps the pack releases it was loaded from (sources).
The character as engines see it
Section titled “The character as engines see it”computeSheet and the other calls work on these views of a character.
Copies and self
Section titled “Copies and self”A copy is an entry in features or inventory. Its view as self:
| Property | Value |
|---|---|
self.id |
The entry’s id |
self.equipped |
The entry’s equipped, default false |
self.quantity |
The entry’s quantity, default 1 |
self.templates |
Qualified IDs of the templates: saved, then kept |
self.<key> |
Each state key declared by the content type, the item or its templates: the entry’s value, or the declaration’s default |
A state key with no saved value and no default is absent (has(self.key) is false).
In the copy’s own actions (their available and steps, for actions defined on its content type
or item and the rules actions they use) and in its follow-ups, self also has the copy’s
computed values as self.<name>. Content picked or granted
on the copy sees the copy’s computed values there too, since its self is that copy. A roll
effect’s roll.self doesn’t have them.
Items and item
Section titled “Items and item”The item a copy holds, as item, is the base item merged with the copy’s templates:
| Property | Value |
|---|---|
item.id |
The base item’s qualified ID |
item.type |
The content type’s name, unqualified (weapon) |
item.tags |
The base item’s tags, then each template’s (less its removeTags) |
item.<field> |
Each item field, read by its declared type. integer, decimal, boolean and dice fields are formulas evaluated with item (the base item) and character bound. A dice field becomes a dice value. A field the item doesn’t set takes its declared default, read as if the item had written it in the content type’s pack; with no default it is absent |
A template’s fields are merged over the base item’s: each template in
the copy’s order replaces the values it gives, a field of the base item’s content type or a
template field. Its values are read like an item’s own, with base bound to the base item
alone, in the template’s pack. A template’s null removes the field: it is
absent (no default applies) unless a later template sets it again. A template field the
content type doesn’t declare and no template sets takes its declaration’s default (read in the declaring pack), else is absent;
this holds for every item’s item, with or without templates. Everywhere a copy’s item is
read (effects, actions, state bounds and defaults, computed values, the candidates of a choice
step over copies and its steps.<id>.item, display groups) and in copy.fields, the fields are
these merged ones.
In a template’s formulas, item is the merged item and base is the base item alone; its field
values also see prior, the item as merged before it. A template’s removeTags
take tags away in template order before its tags are added. The templates are
the copy’s saved ones, then those content keeps on it (content.md).
character
Section titled “character”character.creating is the file’s creating, false when absent.
character.inventory and character.features are lists of copies. Each is
{ id, item, type, equipped, quantity, state }, with item the qualified ID and type the
content type name.
character.applied is the file’s applied entries in file order. Each is
{ id, source, active, state }: source the qualified content ID, active the saved flag
(default true), state the saved values, each state key the source’s content type or item
declares read by its type, with the declaration’s default when unsaved. It is raw: an entry
an immunity blocks is listed as saved, so reading it never depends on the decision it feeds, and
activation conditions may read it.
The copy aggregates (countCopies, sumCopies, maxCopies, minCopies) see each copy as the
copy root: these keys plus tags, templates and fields (the item fields, as in item),
inventory first, then features (formulas.md).
The calls that compute the sheet (computeSheet, listOptions, applyChanges, startAction)
take optional links: by qualified slot ID, a view of the character filling the slot,
{ id, values }. Link values are inputs, like parameters: the engine
reads them and never computes the other character.
- A slot is filled when the character file links it (
linksin the file) or the app passes a view for it. The view’sidis the passed one, else the saved one. - Formulas read
links.<slot>(formulas.md), where<slot>resolves in the formula’s pack, then its dependencies. A filled slot’s view isidplus each declared field, nested at the dots of its field ID (links.crew.upgrades.mastery). An empty slot is absent:has(links.crew)is false. - A declared field takes the passed value whose field ID, without its pack, is the declared ID:
an app may pass computeSheet’s values as they are (
fitd-crew:tier) or bare IDs. With no such value it reads itsdefault; with no default it is absent, so a formula guards it withhas(). A saved link with no view passed reads the defaults. - A value of the wrong type, or two passed values with the same field ID, is
invalid-link-valueinerrors(data:link,field,expected), and the default is used. - A saved link or a passed view for a slot no loaded pack declares is
unknown-linkinerrors; the rest computes. - Link reads add no dependency edges, so they can’t form a
circular-formula.
Standard copies
Section titled “Standard copies”The rules and content items may declare standardCopies (content.md). The engines see the character with them:
- Which. The rules’ standard items are the matches of the rules’ queries, in load order,
then declaration order, each query’s matches sorted, each item once. An item’s are the matches
of its own queries, held while it has an activation. They are found in rounds: activate with
the rules’ copies; add the copies of every item now active, in activation order, that aren’t
there yet; activate again, until a round adds none (at most
maxChoiceDepthrounds). A copy added in a round stays for that computation. The templates content keeps on copies are found in the same rounds: each round also adds, for every activekeepsTemplatesentry whosewhenholds, its template on each matching copy that hasn’t it. The rounds end when a round adds neither a copy nor a kept template. - Instance ID: the item’s qualified ID with the colon and dots as hyphens.
- Saved or not. An entry with a standard copy’s ID and item is that copy, saved, in its
place in the file. An entry with its ID and another item is
duplicate-id(path: the entry’sid) and is left out. Each unsaved copy is an entry{ id, item }after the file’s entries of its list (inventoryfor carried types, elsefeatures), in the order above. - Formula state defaults. Every copy’s state keys whose
defaultis a formula and that have no saved value, an unsaved standard copy’s and a hand-written or saved copy’s alike, are evaluated asaddItemevaluates them (itemthe copy’s item with its templates, saved and kept; input fields only; in the content type’s pack) and held on the sheet’s entry asstate, soself,character.inventoryandcopy.stateread them; one that fails to evaluate leaves its key absent. A saved value is read as saved, and the file isn’t changed.
Everything else (activation, self, character.inventory, countCopies, copy queries,
actions, listOptions paths) sees standard copies as entries like any other.
Activation
Section titled “Activation”Content is active when it has an activation: a reason to apply its effects, grants and
choices. Each activation records its content, its self (a copy, or none), its owner copy,
and its scope (holder or applied):
| Source | self and owner |
Scope |
|---|---|---|
An entry in features |
the entry | holder |
An entry in inventory |
the entry | holder |
| A template on an active inventory or features entry | the entry | holder |
A grant of active content, whose when holds |
the granting activation’s owner | holder |
| A picked content option of an applicable choice¹ | the owner of the choice | holder |
| A picked inline option | the owner of the choice | holder |
An applied entry with active not false |
none (self is the applied entry: id and state) |
applied |
A scope: applied grant of applied content² |
none (self is the granting entry) |
applied |
| The rules (always-on effects, rules-level choices) | none | holder |
¹ Unless the choice says activate: false: its picks run the choice’s
effects and are otherwise inactive.
² While its content is active as applied content, unless an immunity blocks it
(below). A grant without scope (or scope: holder) of applied content isn’t followed, and a
scope: applied grant of held content isn’t either.
An applied entry’s self is id plus its state keys, read as in character.applied: declared
keys by their type, with their default when unsaved.
Activation conditions decide what is active: grant when, choice when, and an
immunity’s when and query where. They are
evaluated before any field is computed, so they may read only self, item, character (its
applied list included, which is raw), entry in an immunity’s query, the character functions,
and the raw values of input fields (saved value, or default). Effects
are not applied to those values. A condition that reads a derived field is rejected by the build
(type-mismatch). PROVISIONAL. This keeps activation free of loops: a class grants features
by self.level, never by a computed field.
Activation is found by walking from the copies, the applied list and the rules. Each grant and
pick is followed once; the same content reached twice by different paths is two activations.
Grants and choices nest at most maxChoiceDepth deep.
Applied grants. An applied activation follows its content’s grants with
scope: applied (the content type’s, then the item’s) whose when holds, an activation
condition with self the entry. Each granted content is activated with scope applied, the
same entry (its ID is the activation’s entry) and self, and item the granted content;
its own scope: applied grants are followed in turn. Granted activations come right after
their entry’s, depth first, as a copy’s grants follow the copy.
Immunities. Content and the rules may declare immuneTo:
applied content the character can’t be affected by, as items or a query, with an optional
when. Immunities are decided during activation, in this order:
-
The holder activations are found first: the rules, then the copies with their templates, grants and picks.
-
Each holder activation’s immunities with
scopeholder(the default; the rules’ have no scope) whosewhenholds become active, in activation order, then the order they are written in. -
Of the
appliedentries withactivenotfalse, one whose source matches an active holder immunity is blocked. The others are candidates. A candidate’s immunities are its source’s immunities withscope: appliedwhosewhenholds, and a candidate blocks every other applied entry (never itself) whose source one of them matches, whether that entry comes before or after it inapplied. -
The candidates are decided in rounds until none is undecided. Each round:
- Every undecided candidate whose blockers (the candidates that block it) are all blocked, or that has none, becomes active.
- Only if step 1 made none active: every undecided candidate has an undecided blocker, so
some of them block each other in a cycle. The earliest undecided candidate (in
appliedorder) that blocks itself through a chain of undecided candidates becomes active. - Every undecided candidate that an active candidate blocks is blocked.
Each round decides at least one candidate, so the rounds end, and the result depends only on the
appliedorder. Once all are decided, the active candidates are activated inappliedorder and their immunities become active. A blocked entry is not activated, so none of its effects, grants or choices apply, and its immunities block nothing. -
Applied grants. A candidate’s immunities (step 3) include those of the content its applied grants reach, except grants an active holder immunity blocks. While an active entry is activated, each content its applied grants reach is blocked when it matches an active holder immunity or an immunity of another active candidate, as an entry whose source it were would be (the query’s
entryis the granting entry). A blocked grant is not activated, nor what it grants; the entry that grants it stays active. It is reportedimmunewithdata: { entry, item }: the granting entry and the granted content.
So an applied immunity blocks a matching entry wherever it is in the list, and order matters only between entries that block each other: two that do are decided for the earlier one, and a longer cycle for its earliest member, whose blockers in the cycle then don’t count against it. Petrified blocks poisoned whether it comes before or after it; once petrified is removed, the poisoned entry still in the list applies again (the poison was suspended).
A source matches an immunity when it is in items (qualified at load; unknown-reference
otherwise), or when it is of the query’s type and the query’s where holds with item bound
to the source’s item (hidden or not). when and the query’s where are activation conditions,
evaluated with the immunity’s activation bound (self, item, character, links), item
rebound to the candidate’s source in where, and entry bound there to the candidate entry: id
and its state keys, as its self. So a query can match by who applied a
condition (entry.by), and a when can read character.applied (an immunity that lapses while
the holder is unconscious). A blocked entry stays in the file; toggleEffect and
removeEffect work on it as before, and it applies again once the immunity goes away. Each
blocked entry is reported as immune, once per matching immunity
(Pending choices and issues).
Effects on a set of fields (effects.md). An effect
whose target is { fields: { where } } is one instance per field its query matches, in ruleset
field order, each with that field as its target. Everything on this page that says “an effect
on a field” applies to each instance: stacking, competing (option ID <…>@<field>),
breakdowns, effects and dependencies. A field whose tags are computed is
matched when it is computed: its tags are evaluated first (a failure, an undeclared tag or a
loop is the field’s error), and an instance whose where doesn’t hold for them doesn’t exist on
this sheet, so it neither stacks nor appears in effects.
Content type effects and grants. Wherever an item is active, its content type’s effects
(minus those the item suppresses) and grants apply first, then the item’s own. Their
competing-option keys are <item id>#type.<effect id or index>.
Choice keys. An activation reads its choices’ picks from its tree. The tree’s own item (the
entry’s item, or a picked option) uses bare choice IDs. A template or granted content in the
same tree uses <qualified owner id>/<choice id>, and its choice paths use that key.
computeSheet
Section titled “computeSheet”Effects
Section titled “Effects”The effect instances are every effect of every activation whose scope matches the
activation’s scope (holder effects for holder activations, applied effects for applied ones),
plus each choice’s effects once per pick (with pick bound). An instance’s source is
{ type: item | template | rules, id, entry? }.
- Situational effects are not part of the sheet; they apply only when an action rolls.
- Effects targeting rolls are listed in
effects, but they apply only when an action rolls. - Effects targeting incoming changes apply only in
applyChanges.
Field values
Section titled “Field values”Each field’s value is computed on demand and remembered. To compute field F:
- Base value.
- An input field takes its saved value, or its
default. - A derived field with a
formulaevaluates it. - Any other field starts at its
default, or empty:[]for sets,0for numbers,falsefor booleans,''for strings, enums and IDs, and a dice value with no dice (written0) for dice. No field type starts asnull.
- An input field takes its saved value, or its
- Grants: for a set field, each active
grantinstance whosewhenholds adds its members.- An
itemgrant adds a qualified ID. - A
taggrant adds a tag. - A
fieldgrant adds a qualified field ID. - A
querygrant adds every match. - A
formulagrant adds the formula’s value. - The result is sorted and has no duplicates.
- An
- Set:
- Collect. Gather the active
setinstances on F whosewhenholds. - Stack. Apply stacking.
- Pick one. If several remain, they are competing options:
- Option IDs: each option’s ID is
<source id>#<n>, the effect’s index in its content’seffectslist (or#<effect id>when the effect has one). Two copies of the same content share their option IDs, so a pick can’t tell them apart; the earlier copy applies. PROVISIONAL. - Recommended: the option chosen by the field’s
recommend, or else bysettings.competing.recommend.highestandlowestcompare values, with ties going to the earlier instance.firstandlastuse instance order. - Applied: the first option, in instance order, whose ID is in the input’s
competinglist, otherwise the recommended one. IDs that name no option are ignored. - Reported: one
competingentry{ field, op: set, options, recommended, applied }.
- Option IDs: each option’s ID is
- The applied value replaces the base.
- Collect. Gather the active
- Add: the sum of the active
addinstances’ values, after stacking. - Multiply: multiply by each active
multiplyinstance’s value, in instance order. For anintegerfield, round after each multiplication with the effect’srounding, else the field’srounding, elsesettings.rounding(defaultfloor). - atLeast / atMost: raise to the highest active
atLeast, then lower to the lowest activeatMost. - Round and clamp.
- An
integerfield’s decimal result that doesn’t come frommultiply(a formula, or anaddwith a decimal value) is rounded with the field’srounding, or elsesettings.rounding(defaultfloor). - A derived value outside the field’s
min/maxis clamped. - For an input field,
min/maxcheck only the saved value. A saved value outside them is reported inerrors(out-of-range) and clamped before effects apply. Effects may then take the final value past them.
- An
Instance order is the order activations are found:
- The rules.
features, theninventory, in file order. Each copy is followed by its templates, its grants and its picks, depth first.applied.
Within one content item, effects keep their written order.
An effect’s when and value may read other fields. A field reached again while it is still
being computed is a loop: every field in the loop gets error: circular-formula. A field
that reads a field with an error gets that error too. Every other field still calculates.
breakdown lists, in application order, each instance that changed the value:
{ op, value, source }.
tags lists the field’s tags on this sheet: its written tags, then those its
{ formula } entries give (rules-and-fields.md), each
once: the tags effects on sets of fields, field parameters and allocate steps match it by. When
its computed tags fail (the field then has error), it lists the written ones. A field with no
tags has none. An app lists a field parameter’s options before a roll by these tags.
name is the value of the field’s nameFrom string field
(rules-and-fields.md) when it is a non-empty
string (one of only spaces counts as empty), as saved; absent otherwise. The field’s text
formats it as {name}.
Stacking
Section titled “Stacking”Instances on the same target with the same stackKey form a group. The reserved key source
means the instance’s source content ID, so two instances from the same item share it. Each
group is reduced by the policy for its key (settings.stacking.keys, else
settings.stacking.default, else addAll):
addAll: all count.highestOnly: only the highest value counts, ties going to the earlier instance. Dice values compare by average (a die averages (sides + 1) / 2, a Fate die 0); a number is its own average.lowestOnly: the reverse ofhighestOnly.playerChooses: the instances become competing options, as forset, with the highest recommended. The group is its owncompetingentry, with itsopandstackKey(the key as written, sosourcefor the reserved key), and is picked independently of the field’s other groups. OnlycomputeSheetreads picks: incoming-change effects, and the sheets thatlistOptions,applyChangesand actions compute, keep the recommended option.
Instances that don’t count are listed in effects with suppressed: true. Instances without a
stackKey always count.
Pending choices and issues
Section titled “Pending choices and issues”For each activation’s choices, and the rules-level choices:
- Not applicable: the choice’s
whenis false.- Its picks are reported as
choice-not-applicable. - Its picked options are not activated.
- Its picks are reported as
- Applicable: the choice’s
pickgivesminandmax(an exact count sets both).- With fewer picks than
min(andrequirednotfalse), the choice is pending. - With more than
max, the issue istoo-many-picks.
- With fewer picks than
- The order of
pendingis a pre-order walk of the activations: the rules-level choices first, then each activation’s own choices in declaration order, each followed at once by the choices of the content and inline options picked in it (in the same order, recursively), then those of the other activations it reaches (its templates and grants), in activation order. A starting rogue is asked its four skills before the Expertise the class grants, and its racket’s skill right after the racket, before the class’s later choices. A choice step over pending choices, andfinishCreation’stoo-few-picksissues, follow the same order. pendingis the open entries of the choice list, in the same order, each as{ path, min, max, picked, slot },pickedthe number of picks.- Choice issue IDs:
choice-not-applicable,too-many-picksandoption-not-eligiblecarryid: <owner>/<choice id>, where the owner is the content ID of the item that declares the choice (orrules), andsourceis the owner’s activation. - Slots: a slotted choice is one choice per slot, each keyed
<id>-<slot>and gathered in list order where the choice is declared, withslotbound in its formulas (content.md). Each is pending, applicable or not, and reported on its own, under its key. A pending slot carriesslot: { choice, number }, the declared ID (whose prompt names it) and the slot’s number. computeSheet,listOptionsandapplyChanges’chooseandunchoosesee the same choices.
Rules issues, all reported in issues:
prerequisite-not-met, for each prerequisite of active content (and of picked inline options) that is false, unless the grant or choice that activated it waives them (waivePrerequisites).idis the qualified owner plus the prerequisite ID (5e-srd-5.1:classes.fighter/multiclass).sourceis the activation.
no-eligible-option, for each pending choice of active content that saysrequiresOptionand has no eligible option not yet picked, aslistOptionslists them.idis the qualified owner plus the choice ID;datais{ path }.sourceis the owner’s activation. listOptions’ trial sheet for an option reports it with the option’s prerequisites.
constraint-failed, for each rules constraint whosecheckis false; a constraint withcopiesfor each copy it fails on, in copy order, withdata.entry.idis the constraint’s qualified ID.datais its evaluateddata.
option-not-eligible, for a picked option that no longer matches its choice’s query, and for a picked value that fails its choice’swhereor that its formula no longer lists, and for a pick that fails its choice’seligible.content-hiddencovers hidden content picked in a choice withactivate: falsetoo; it belongs to the choice’s owner.content-hidden, for owned or picked hidden content.immune, for each blockedappliedentry and each active immunity its source matches: the holder’s, then the active candidates’ inappliedorder (Activation).idis the immunity’s qualified owner plus its ID (5e-srd-5.1:features.monk.purity-of-body/poisoned); for the rules’, its pack plus its ID (5e-srd-5.1:listed).sourceis the immunity’s activation (for the rules’,{ type: rules, id }with the sameid).datais{ entry, item }: the applied entry’s ID and its source.
More issues:
already-picked, for each pick of an option already picked earlier (in activation order) in another choice with the sameuniquegroup, unless the option isrepeatablecontent.datanames the earlier choice’s path. A local key is the declaring pack’s group and a qualified one that pack’s; withuniqueScope: owner, only the choices of the same owner activation share the group.out-of-limits, for a field whose final value is outside itslimits.idis the field, anddatais{ field, limit, value, bound }.too-many-copies, for a content type whose copies exceed itscopies.max, and for an item whose copies exceed its type’scopies.perItem(Copy limits).
Order. Each issue belongs to an activation: a prerequisite or hidden content to the
activation of its content, a choice issue (choice-not-applicable, too-many-picks,
option-not-eligible, already-picked) to the activation that owns the choice, an immune
issue to the immunity’s activation, and a constraint or a rules immunity to the rules, which come
first. A too-many-copies issue belongs to the rules too; a no-eligible-option issue to the
activation that owns the choice. Issues are listed by activation
order, then by kind: prerequisite-not-met, no-eligible-option, constraint-failed,
too-many-copies,
choice-not-applicable, too-many-picks, option-not-eligible, already-picked,
content-hidden, immune. Issues of the same activation and kind keep the order their
prerequisites, constraints, choices and picks are written in; too-many-copies issues go by type
in ruleset order, a type’s max issue before its items’ in the order of their first copy;
immune issues follow the applied order, then the order the immunities are written in.
out-of-limits issues belong to no activation and come last, in field order.
Actions
Section titled “Actions”An action is available when its available formula (if any) is true. Two kinds of action are
listed in actions:
- Content actions: the actions of each activation that has an item: its content type’s
actions, then the item’s own, then its templates’. A holder activation lists those with
scope: holder(the default); an applied entry’s activation those withscope: applied, with the entry’s ID asentryand nosource. Its actions run withitemthe source’s item andselfthe entry (idand its state keys, as its effects see it, without computed values). Content an entry grants withscope: appliedlists itsscope: appliedactions with the entry’s ID asentryand the content’s qualified ID assource.- An activation with an owning copy is listed with the copy’s
entry. An action of content picked in a choice or granted to the copy, rather than the copy’s own item, also carriessource: that content’s qualified ID. Several picks can then use the same shared action. - An activation with no owning copy (the rules’ picks and everything nested in them) is
listed with
sourceand noentry. Its actions run withitembound to the content and noself, as for rules-level activations’ effects. - Each action is listed once per
(action, entry, source), so the same content reached by two paths with no copy is listed once.
- An activation with an owning copy is listed with the copy’s
- Rules actions: shared actions that no content type or item
uses, listed without anentry.
An item action written as use is listed under the used action’s qualified ID. An action
defined inline on an item is listed as <item id>#<action id>.
Order: content actions come first, in activation order, each activation’s actions in the order above. Rules actions follow, in definition order: packs in load order, then each pack’s rules files in path order.
Computed values
Section titled “Computed values”After every field, each copy’s computed values (content.md)
are worked out, copies in instance order (features, then inventory, in file order), each
copy’s names in declaration order: the content type’s, then the item’s and each template’s, a
later declaration replacing an earlier one with the same name in its place.
- A formula resolves names in the pack that declared it (an override’s in the overriding pack)
and sees
itemandselfas the copy’s effects do, every field (fields never read computed values, so they are all computed first),character,linksand the character functions.selfhas the copy’s computed values declared before this one, each as worked out; one that failed is absent. A replacement keeps the replaced name’s place, so the values after it read the replacement, and it reads only what comes before the name. - An
integervalue must be an int, adecimalvalue a number (an int becomes a decimal), abooleanvalue a bool and astringvalue a string. - A formula that fails, or gives a value of another type, gives no value and the error
formula-error(data:entry, the copy’s instance ID;name;cause, the formula error code ortype-mismatch).
copies maps each copy that has any computed values to { <name>: value }. The same values
are self.<name> in the copy’s actions and follow-ups (Copies and self);
an engine may compute them on demand, with the same result.
copyNames maps each copy whose item or content type has nameFrom
(content.md) to the value of that state key, when it is a
non-empty string (not only spaces); an item’s nameFrom replaces its type’s. Copies without one
are left out.
The activation list
Section titled “The activation list”activations lists every content activation (Activation), in instance order:
the rules-level picks, then each copy with its templates, grants and picks,
depth first, then the active applied entries with their applied grants. Each is
{ item, entry?, via, scope?, parent?, keptBy?, path? }:
item: the content’s qualified ID (the base item; a template is its own activation).entry: the owner copy’s instance ID, or for applied content the applied entry’s ID; absent for content picked or granted at the rules level.via: how it was reached:copy(afeaturesorinventoryentry),template(on a copy),grant(granted by active content, itswhenholding),pick(a content option of an applicable choice that activates its picks) orapplied(anappliedentry, or ascope: appliedgrant of one).scope:appliedfor applied activations; absent for holder ones.parent: the index in this list of the activation that granted it, owns the choice it was picked in, or owns the template: the nearest listed one, so content picked in an inline option’s choice has the inline option’s owner. Absent for copies, applied entries and content picked or granted at the rules level.keptBy: for a template content keeps on the copy, the index of the activation that keeps it (the first, when several do). Absent for a saved template.path: for a pick, the choice path of the pick (change.schema.json#/$defs/choicePath), the picked option last, aspendingand change paths name it.
Inline options, the rules and applied entries an immunity blocks (which aren’t activated) aren’t
listed, nor are the picks of a choice with activate: false. A content group’s from: own
members are the holder activations whose item is of the group’s type
(display-hints.md); a template’s activation is part of its copy’s member.
The choice list
Section titled “The choice list”choices lists every applicable choice (Pending choices and issues),
answered or not, in the order of pending: the rules-level choices, then each
activation’s own choices in declaration order, each followed by the choices of what it picked,
then those of the activations it grants.
Inline options’ choices and choices with activate: false are listed like any other, and a
slotted choice is one entry per applicable slot. A choice that isn’t applicable isn’t listed
(its picks are choice-not-applicable issues). Each is
{ path, owner, id, slot?, type?, min, max?, picks, open }:
path: the choice path (change.schema.json#/$defs/choicePath), aschoose,unchooseandlistOptionstake it.ownerandid: as a choice step’sneeds.choice(engine-actions.md): the qualified key root of the choice’s owner (the content ID,<pack>:rulesfor a rules-level choice, and for a choice nested in an inline option the owner’s root plus.choices.<choice>.options.<option>) and the choice’s ID (a slot’s own,<id>-<slot>). The prompt is<owner>.choices.<id>.prompt, underslot.choicefor a slot.slot: for a slot,{ choice, number }, as inpending.type: the qualified content type the choice draws from: a query’stype, or the one type every item of a writtenitemslist shares. Absent for inline options, values and items of several types. Display hints’hasChoicereads it (formulas.md).minandmax: the evaluated pick counts, aslistOptionsgives them (maxabsent when unbounded; a count that fails to evaluate leaves the default, 1).picks: the picked options, in saved order: content IDs, inline option IDs or values.open: whether the choice is pending:requirednotfalseand fewer picks thanmin.
The entries with open: true are pending. Each content pick in
the activation list has a listed choice’s path plus the option; inline
options and activate: false picks are listed only here. A creation wizard shows each entry as a
step, answered or open, and swaps an answer with unchoose then choose in one batch.
Copy limits
Section titled “Copy limits”For each content type with copies (content.md), in
ruleset order (packs in load order, then declaration order), after activation:
- The copies are the character’s
featuresandinventoryentries whose item is of the type, in instance order, standard copies included, whatever theirquantity. An entry whose item is unknown counts for no type. copyLimitshas one entry per type:{ type, max, perItem, count, entries }, withtypequalified,maxandperItemas declared or, for a formula, its value on the computed sheet (absent when not declared, or when the formula fails: aformula-errorwithwhere: '<type> copies <key>'),countthe number of copies andentrieseach copy as{ entry, item }.too-many-copieswhencountexceedsmax:idthe qualified type,source{ type: rules, id }with the sameid, anddata: { type, max, count, entries, limit },entriesthe copies’ instance IDs andlimitequal tomax.too-many-copiesfor each item of the type with more thanperItemcopies, in the order of its first copy: the same, withdata: { type, item, perItem, count, entries, limit },countandentriesthe item’s andlimitequal toperItem.
An app’s rule: at count >= max, Add becomes Replace (when max is 1) or is disabled; an item
with perItem copies is shown as held. A replace is one applyChanges batch, removeItem then
addItem reusing the instance ID (applyChanges).
Offers
Section titled “Offers”offers lists what the rules offer the character now: its stored ephemeral
follow-ups, then its derived offers whose condition holds. See the offer list.
Situational choices
Section titled “Situational choices”situational lists the qualified IDs of the situational choices offered now: those whose
available holds, or that have none, in definition order (packs in load order, then each pack’s
rules files in path order). A condition that fails to evaluate counts as false. The list holds
choices for rolls and for incoming changes alike; an app offers a choice for a
change when its incoming matches the change.
Errors
Section titled “Errors”computeSheet never fails. Problems with the character file are listed in an errors array
(the codes are runtime error codes), and the sheet is computed without the offending part:
- An entry whose item is unknown (
unknown-id) is skipped. - A saved value for an unknown or derived field is ignored (
unknown-field,not-input-field). - A wrong-typed input value is ignored, using the default (
wrong-type). - Pinned versions that differ from the ruleset’s are reported (
pack-version-mismatch). - A link to an undeclared slot is ignored (
unknown-link); a linked value of the wrong type reads the field’s default (invalid-link-value). - A computed value whose formula fails is left out (
formula-error). - An entry with a standard copy’s ID and another item is skipped (
duplicate-id); the standard copy is unsaved.
listOptions
Section titled “listOptions”Input: a choice path. Output: every option of the choice, in a stable order:
- listed
itemsandvaluesin written order, avaluesformula’s list in its order (each value once); - inline options in written order;
- query matches sorted by qualified ID.
For each option:
eligible: the option has no reasons.reasons: the rules issues picking it would add, includingalready-pickedwhen the option is picked in another choice of the sameuniquegroup (unless the content isrepeatable). These are the option content’s prerequisites (evaluated with the option active, as if picked; none for a choice withactivate: false), withno-eligible-optionfor a choice of its own that requires an option and would have none, and, for a query whosewherereads the character or a value choice’swhere(valuebound to the candidate),option-not-eligiblewhen the option fails it, or fails the choice’seligible. These formulas seechoice, the choice with its ownpicks(content.md).picked: whether it is currently picked.recommended: whether the choice’srecommendnames it; a choice withoutrecommendmarks none (there is no default).
min and max are the choice’s evaluated pick counts (max is absent when unbounded). An
unknown path is the error unknown-choice, returned as { errors }.
applyChanges
Section titled “applyChanges”The changes apply in order to a copy of the character. If one fails, the whole batch is rejected
with its error (path: /changes/<index>) and the character is unchanged. After the last change,
the sheet is computed: its rules issues are returned, and in strict mode any issue rejects the
batch (rules-issue, with the issues attached).
Replacing a copy is one batch, removeItem then addItem, which may reuse
the removed entry’s instance ID:
- { type: removeItem, entry: class }- { type: addItem, entry: { id: class, item: mothership-1e:classes.teamster } }The changes apply in order, so the ID is free again for the addItem, and the sheet (and strict
mode) sees only the result: a type at its copies.max takes the swap without too-many-copies.
The old copy’s picks, state and templates go with it.
What the batch did
Section titled “What the batch did”applyChanges binds batch in the formulas it evaluates: a change aimed at
copies or applied entries (where, by, value) and an applyEffect’s state value given as
{ formula }. batch.adjustments lists the adjustments entries of the batch’s earlier
changes on this character that change a field, in order, each { field, by, requested, reason, clamped, tags }, as an on follow-up’s change has them (requested 0 and clamped false
when absent). An action passes on a formula that reads batch (a state value as { formula }),
so it learns what its own earlier changes did to the target: 5E’s wound holds the hit points
the target lost to the batch’s damage, after its resistances and temporary hit points.
An applyEffect state value { formula } is evaluated with item the source’s item, the
character’s fields and batch, in the source’s content type’s pack; the value is then checked
as any state value is, and the change as applied (the result’s changes) carries it, as an
adjust carries its rolled faces. The engine stays a reducer: the value depends only on the batch
and the character. An app applies an action’s changes for one character as one batch.
applyChanges also takes an optional dice function, outside the JSON shape, as startAction
does; only incoming-change effects with dice use it (below). A
successful result returns changes: the batch as applied, with the faces it rolled written in.
| Change | Runtime checks (error code) | Effect |
|---|---|---|
set |
field exists (unknown-field), is input (not-input-field), value type (wrong-type), within min/max (out-of-range); set members are declared tags or existing IDs (unknown-id) |
Stores the value |
adjust |
as set, numeric; its situational choices known (unknown-id), typed (wrong-type), and each selected one offered with a matching incoming (situational-unavailable) |
Routed (below) |
addItem |
instance ID unused (duplicate-id); item exists (unknown-id); templates are templates and apply (template-not-applicable); equipped only if equippable (not-equippable); state keys declared (unknown-state-key) and typed (wrong-type); a formula default within the key’s min/max (out-of-range); each pick, at any depth, names a choice of the copy (unknown-choice) and one of its options (invalid-option), as a choose would |
Adds the entry to inventory (carried types) or features |
removeItem |
entry exists (not-owned); not a standard copy (standard-copy) |
Removes it with its picks |
equip |
entry in inventory (not-owned), equippable (not-equippable) |
Sets equipped |
setState, adjustState |
entry exists (a copy, or an applied entry by its ID), key declared (for an applied entry, by its source), type, numeric for adjust, within the declaration’s min/max (out-of-range), evaluated for the copy with item bound (content.md) |
Stores the value |
setQuantity, adjustQuantity |
entry exists (not-owned); an integer (wrong-type); the new quantity not below 0 (out-of-range) |
Sets quantity, or adds by to it (default 1). A copy at 0 stays |
applyTemplate |
entry exists; template is a template (unknown-id) whose appliesTo matches the item (template-not-applicable); not already applied (duplicate-id) |
Appends it |
removeTemplate |
applied (not-picked) |
Removes it and its state values |
applyEffect |
ID unused when an entry is added (duplicate-id; without one, generated: below), source is content and not a template (unknown-id), state keys declared (unknown-state-key), typed (wrong-type) and within their min/max (out-of-range) |
Adds an applied entry, or merges into one (reapply, below) |
removeEffect, toggleEffect |
entry exists (not-owned), or by query (below) |
Removes it, or sets active |
choose |
the path leads to a choice (unknown-choice); the option is one of its options (invalid-option; a values formula is evaluated on the sheet before the change) and not already picked (duplicate-id) |
Appends the pick |
unchoose |
the path leads to a choice (unknown-choice); the option is picked (not-picked). Without option: nothing |
Removes the pick and its nested picks; without option, every pick and their nested picks (none to remove is no change). The choice keeps an empty list |
setExtension |
— | Stores or (with null) removes the namespace’s data |
finishCreation |
the character, with the batch’s earlier changes applied, has no rules issues and no pending choices (creation-incomplete; its issues, pending choices as too-few-picks, are also the failed result’s issues) |
Clears creating. Nothing on a character not being created |
setHomebrew |
the new homebrew loads (homebrew-error, paths into the change: /changes/<index>/homebrew/…); the rest of the file names no homebrew: content or field it lacks (unknown-id) |
Replaces the homebrew, or removes it with null |
dismissFollowUp |
a follow-up with the id is stored (unknown-follow-up) |
Deletes it, required or not (Follow-ups) |
clearFollowUp |
— | Deletes the stored follow-up with the id; none is no change |
offerFollowUp |
a loaded pack declares the follow-up (unknown-follow-up) |
Stores it as an ephemeral offer after the batch, when the character has it |
Eligibility (prerequisites, pick counts) is never an error. It shows up as rules issues.
setHomebrew loads the new homebrew on the ruleset as every call does
(above), and later changes in the batch use it. Then every homebrew:
ID the rest of the file names must still exist: entries’ item and templates, applied
entries’ source, picked options at any depth, the owner part of prefixed choice keys, members
of saved sets, and values keys. To delete a custom item in use, the batch removes its uses
first. A batch whose first change is setHomebrew doesn’t load the character’s current
homebrew, so an app can repair one that no longer loads (after an upgrade, say).
Standard copies. A change to an unsaved standard copy writes it to the
file first, as { id, item } appended to its list, with its formula state defaults as the
sheet evaluated them (state), then applies as usual: equip, setState,
adjustState, setQuantity, adjustQuantity, applyTemplate, removeTemplate, and
choose or unchoose with the copy’s entry in the path. A query by copies matches unsaved
copies too. removeItem on a standard copy, saved or not, is standard-copy; addItem and
applyEffect with a standard copy’s ID are duplicate-id. After the last change, a saved
standard copy back at its defaults (not equipped, quantity 1, no templates or picks, every saved
state value its key’s default) is removed from the file.
Immunity is never an error either. applyEffect of a source the
character is immune to succeeds: the entry is added, blocked, and the result reports immune,
so strict mode rejects the batch (rules-issue). A change that gains an immunity while a
matching entry is applied (a monk reaching level 10 while poisoned) reports the same issue, so
the app can remove the entry.
Applying content again. An applyEffect whose source declares reapply
(the item’s, else its content type’s; content.md) looks for
an applied entry with the same source, active not false (a blocked entry counts: the
content is on the character, only suspended), and the same value for each match key (the new
state’s values with their defaults, against the existing entry’s). If there is one, the first in
file order, no entry is added: each key in state of that entry becomes the higher of the two
(highest; dice compare by average, as in stacking), the lower (lowest), their sum (add) or
the new value (replace). A key the new state doesn’t give takes its default; one with neither
value is left as it is. Other keys keep the existing entry’s values. The result is checked as a
setState would be (wrong-type, out-of-range), and the change adds { applied: [<the entry's ID>] } to adjustments. An id the change gives is not used, nor checked. Without a
match, the entry is added as usual. Replaying the batch gives the same result, since the merge
depends only on the character and the change.
Changes aimed at copies. setState, adjustState,
setQuantity, adjustQuantity, equip and removeItem may give copies: { type, where }
(and optionally limit) instead of entry:
- The query is resolved against this character at the point the change applies in the batch,
so earlier changes are visible. Matches are the copies of the content type
type(qualified), inventory first, then features, each in file order, whosewhereholds withitem(the copy’s item with its templates) andself(the copy) bound. Withlimit, only the first that many are changed. byandvalueare formulas, evaluated per match with the same binding. Names in these formulas resolve in the content type’s pack.- The change is then applied to each match as if it named that
entry, with the same checks. - An unknown
typeisunknown-reference. No match is not an error:adjustmentsgets{ copies: [] }. Every such change adds{ copies: [<matched instance IDs>] }toadjustments, in order.
Applied entries. applyEffect may leave out id: the entry gets
<source local id, dots as hyphens>-<n> (conditions.poisoned gives conditions-poisoned-1),
the smallest n ≥ 1 that no copy, applied entry or earlier change in the batch uses. The
character receiving the change picks it, so an action needs no knowledge of a target’s file, and
replaying the batch on the same character gives the same ID. removeEffect, toggleEffect,
setState and adjustState may give applied: { where, limit? } instead of id (or entry):
- The query is resolved against this character at the point the change applies in the batch.
Matches are its
appliedentries, in file order, whosewhereholds withitemthe source’s item andselfthe entry (idand its state, each key the source declares read by its type, with itsdefaultwhen unsaved, as on the entry’s own effects). Withlimit(a literal integer ≥ 1), only the first that many are changed. byandvalueare formulas, evaluated per match with the same binding. Names in these formulas resolve in the source’s pack.- The change is then applied to each match as if it named that entry, with the same checks.
- No match is not an error. Every such change adds
{ applied: [<matched instance IDs>] }toadjustments, in order.
setState and adjustState may also name one applied entry by its instance ID in entry (a
copy’s ID is looked up first). The keys, types and bounds are those the source’s content type
and item declare.
A pick’s state. setState and adjustState may name a pick with path (as
choose’s) and option instead of entry: a pick that isn’t there is not-picked; the keys,
types and bounds are those the picked content’s type and item declare. A value equal to the
declared default is dropped from the pick (an empty state with it). picks: { where, limit? }
names every pick, in file order (each copy’s tree, then the rules-level tree, depth first),
whose picked content declares the change’s key and whose where holds with item the picked
item and self its state; by and value are evaluated per pick. No match changes nothing.
Quantity. setQuantity and adjustQuantity change a copy’s
quantity (any copy, in inventory or features; absent means 1). The result is always written,
1 included. It never goes below 0: such a change is out-of-range and rejects the batch, so an
action that spends one aims at where: self.quantity > 0 instead. A copy at 0 stays, with its
picks, templates and state; removing it is a separate removeItem, which by query sees the new
quantity.
Formula defaults. When addItem adds a copy, each numeric, boolean or dice state key whose
default is a formula and that has no value is evaluated with item bound (the copy’s item
with its templates) and input fields only. A numeric result outside the key’s min/max
(evaluated the same way) rejects the change with out-of-range, as setState
would; otherwise the result is written into the entry’s state. Values the change gives
explicitly, and literal defaults, are checked for type only. applyTemplate does the same for
the state keys the template itself declares that have no saved value, with the copy’s
templates, the new one included: a staff template’s
charges: { default: item.maxCharges } is saved when it is applied.
Routing an adjust
Section titled “Routing an adjust”An adjust on field F with amount by, tags and optional situational choices:
- Incoming-change effects. Take the active instances whose target is
incoming, whosefields(if given) include F, and whosetags(if given) match one of the change’s tags. After the character’s own come the incoming-change effects of each situational choice the change selects (below), with source{ type: situational, id }. Group them by operation and apply stacking to each group (dice compare by their average);playerChooseskeeps the recommended (highest) instance, since there is no pick to read. Each countedaddwhose value is dice is rolled, in instance order (below). Apply them in effect order to the amount:set,add(a rolled one adds its total),multiply, thenatLeast/atMost. Round each multiplication with the effect’srounding, else the field’s, else the game’s: the signed amount whensettings.roundIncomingisvalue, its magnitude (sign restored) when it ismagnitude. The sheet used is the character before this change. The effects’whenandvalueseeincoming:amountisbyas requested, before any effect, so every instance sees the same number;fieldis F’s qualified ID;tagsare the change’s tags. They also seesituational: the choices visible from the effect’s pack, each with the value the change gives, else its default. An instance counted when itswhenheld and stacking kept it. - Absorbing. If the amount is negative, each field in F’s
absorbedBy, in order, takes as much of it as it can before reaching its minimum. - The field takes the rest.
- Overflow. If the result passes the bound of one of F’s
overflowentries (at: max: abovemax;at: min: belowmin; or reaches it, wheninclusive), the engine adjustsintobyby(default 1), and F is set toreset. The adjust onintois routed in turn (without incoming-change effects), up tomaxOverflowChainadjusts in all. - Clamping. Otherwise the result is clamped to
min/max. then. After the adjust is fully routed, each instance that counted in step 1 applies itsthenchanges (setStateoradjustState), in instance order, to its own copy: the copy whoseselfit sees (for picked or granted content, the owning copy), before the next change in the batch. Theirvalueandbyare evaluated with the instance’sitem,selfandincoming, on the same sheet as step 1. They are checked like the same changes given to applyChanges, and a failure rejects the batch. They are not adjusts, so nothing routes or triggers on them. An instance with no copy (content picked in a rules-level choice) skips them;thenwhere no copy can exist is a load error (then-without-copy).
Each step that changes a field is reported in adjustments, in order, as
{ field, by, requested?, taken?, reason, clamped? }, with qualified field IDs:
absorbed: one entry per absorbing field that took part of the amount.requested: the field the change named.byis what the field took, after effects, absorbing and clamping.requestedis the change’s original amount.takenis the amount after step 1’s incoming-change effects, before absorbing, clamping and overflow. It differs fromrequestedonly when an incoming-change effect counted. Other entries have none.rolled(only when an incoming-change effect rolled dice) lists each roll in instance order as{ source, dice, faces, total }: the effect’s source with its effect ID, the dice as text, every face used, and the total.- With an overflow,
byis the unclamped amount, so the value may briefly be past its bound.
overflow: the adjust oninto, followed by that field’s own steps if it overflows in turn.reset: the jump of the named field to itsresetvalue.carry: withcarry: true, the excess routed into the field again, its entry{ field, by, reason: carry }followed by its own steps if it overflows in turn.
then: after the adjust’s own entries, one{ entry, key, value, source }perthenchange: the copy, the state key, its new value, and the effect instance’s source with its effect ID (effect, when the effect has one).
clamped: true appears only when clamping changed the amount. So FitD stress at 8 taking 3
reports stress +3 (requested), trauma +1 (overflow), then stress −11 (reset). Mothership
armor with AP 5 and DR 2 taking 8 damage reports health −1 (requested, from −8 + 5 + 2), then
{ entry, key: destroyed, value: true }.
The change log holds only the adjust: replaying it routes the same way and applies the same
then.
Situational choices on a change
Section titled “Situational choices on a change”An adjust may carry situational: the receiving player’s
situational choices for it, a map of
qualified IDs to values, such as 5E’s Uncanny Dodge on one attack.
- An unknown ID is
unknown-id; a value of the wrong type for the choice iswrong-type. - A choice is selected when it is a boolean that’s true, or another type with a value other
than its default. A selected choice must be offered on the character before the change
(
available, as computeSheet lists it) and have anincomingthat matches the change (itsfields, if given, include F; itstags, if given, match one of the change’s). Otherwise the batch is rejected withsituational-unavailable(data:situational, the qualified ID). - Each selected choice’s incoming-change effects join step 1 after the character’s own, in choice order; its roll effects don’t apply. A choice left at its default adds nothing.
- The choices reach only this change: not its overflow (which skips incoming-change effects) and not the batch’s other changes. An attack split into several adjusts carries them on each.
changesreturns the adjust with itssituational, so a logged batch replays the same.- An action never proposes
situational: the target’s player chooses, on the target’s side.
Dice on incoming changes
Section titled “Dice on incoming changes”An incoming-change add may be dice: MÖRK BORG armor takes a d2, d4 or
d6 off every hit. applyChanges stays a reducer that replays exactly, because the faces live in
the change:
- Faces. An
adjustmay carryfaces, a list of integers. Each counted diceaddof its routing is rolled in instance order by formulas.md, taking faces first fromfaces, in order, then from the dice function, if one was given. Overflow adjusts skip incoming-change effects, so they never roll. - Too few. When
facesruns out and there is no dice function, the batch is rejected withfaces-required(path: /changes/<i>). Its data lists every diceaddthe adjust rolls,dice: [{ source, dice }]in instance order, andsupplied, the number of faces given. An app that wants the player (or a server) to roll catches it, rolls those dice and resends the batch withfaces. - Too many. Faces left over after the adjust is routed are
faces-mismatch(data{ supplied, used }). A face off the die, given or from the dice function, isinvalid-face. - The output.
changesis the batch as applied: each adjust that rolled carries every face it used infaces, supplied and rolled; every other change is as given, so a batch that rolled nothing comes back unchanged. Therequestedentry ofadjustmentslists the rolls (rolled). - Replay. Apps log
changes, not their input. A logged adjust holds its faces, so replaying it needs no dice function and builds the same character. Replayed without one, a log that lost its faces fails loudly withfaces-requiredinstead of rolling again.
The proposed sheet applies its batch with no dice
function, so a step that reads it after a proposed adjust that would roll fails with
proposed-changes-failed.
Follow-ups
Section titled “Follow-ups”Follow-ups (rules-and-fields.md) are of two kinds: derived
offers (while), which computeSheet works out from the character, and ephemeral ones (on,
becomes), which the applyChanges that triggered them stores in the character file (followUps)
until they are acted on or dismissed. The engine runs and applies neither.
- What the character has. The rules’ follow-ups in load order, then each holder activation’s
in instance order (a content type’s before the item’s): copies, their templates, and content
they grant or pick. Content in the
appliedlist has none. - Offered. Each
offerFollowUpchange of the batch, in order, names a follow-up (followUp, andsourcefor content’s) that a loaded pack must declare (unknown-follow-upotherwise, rejecting the batch). After the batch it is the declaration the character has with thatfollowUpandsource, as a stored one is found; with none, or a derived (while) one, nothing is stored. Otherwise it fires as a triggered one does (itswhenand action checked, below), itsparamsthe declaration’s with the change’s over them, and nocause. Offered follow-ups come before the triggered ones. on. After a successful batch (strict mode included): for each entry ofadjustmentson the follow-up’s field, in order, whose tags include one ofon.tags(a tag matches its subtags), or any such entry withouton.tags. An entry’s tags are the change’s tags forrequestedandabsorbedentries, the overflow’stagsforoverflowentries, and none forresetentries;adjustmentsdoesn’t report them.changeis{ by, requested, taken, reason, clamped, tags },requestedandtaken0 andclampedfalse when the entry lacks them.once: the follow-up fires at most once per batch, when at least one entry matches, andchangeis the matching entries summed:by,requestedandtakenthe sums (absent parts counting 0),tagsthe union in entry order,reasonthe first entry’s,clampedtrue if any entry’s is.whenand every formula are evaluated once, on the sum.
becomes. The condition is evaluated on the character after the batch and, for the same declaration (the rules’, or the same content on the same copy and choice path), on the character before it, with the same links. It fires when it holds after and not before. Content the character didn’t have before counts as false before. It isn’t stored while an entry from the same declaration (the samefollowUpandsource) is still stored after the batch’s lapsing (below), andfollowUpsdoesn’t list it then.- Storing.
whenand every formula are evaluated on the character after the whole batch. A follow-up that fires is stored, in the order above, as{ id, followUp, source?, to?, character?, params?, cause? }(character.schema.json#/$defs/followUp):id:<the follow-up's own ID>-<n>, the smallestnfrom 1 that no stored follow-up uses, those stored earlier in the batch included;followUp:<pack>:<id>for the rules’,<qualified content or type ID>#<id>for content’s, withsourcenaming the content and copy as computeSheet’s sources do;to: for a follow-up to a link,{ link }with the qualified slot, andcharacterthe linked character’s ID when the slot is filled. It is stored on this character, since one applyChanges writes one file;params: for an action, the evaluated parameters it fixes, leaving out any whose formula givesnull;cause: foron, thechangeit fired on (withonce, the sum) and itsfield.
- Not stored: a follow-up whose
whenis false, whose action isn’t available after the batch (itsavailableis false; for a link the action isn’t checked), or whose formulas fail to evaluate. - Output. applyChanges’
followUpslists the follow-ups this batch stored, as the offer list lists them.
Storing more than maxFollowUps in one batch, counting each copy of a forEach change in a
stored follow-up’s changes as one more, rejects the batch with
safety-limit-exceeded. Storing depends only on the batch and the character, so replaying a
change list stores the same follow-ups; acting on or dismissing one is a change in the list too.
Lapsing. A stored entry whose follow-up is a becomes one is owed only while its
condition lasts. After every successful batch, before anything is stored, applyChanges deletes
each stored entry whose follow-up is a becomes one and:
- whose declaration (the one the character has with the same
followUpandsource, as the offer list finds it) has a condition that is false on the character after the batch, or fails to evaluate.whenisn’t part of it: it gates only the moment the follow-up fires; - whose declaration the character no longer has, though a loaded pack declares that
followUpas abecomesone: content the character lost counts as false; or - that follows another stored entry from the same declaration, in file order.
The deletion is not a change in changes, and followUps doesn’t report it. on entries are
events (two hits call for two concentration checks, each stored until run or dismissed) and
trigger-less ones have no condition; neither lapses. An entry whose followUp no loaded pack
declares is kept. A becomes follow-up that an offerFollowUp change names is stored only if
its condition holds after the batch, and not while an entry from its declaration is stored.
Clearing. clearFollowUp (proposed by startAction for a stored follow-up’s run, and last in
its listed changes) and dismissFollowUp delete the stored entry with their id. Clearing one
that isn’t stored is no change, so an action’s changes still apply after another app cleared it;
dismissing one is unknown-follow-up. An on or trigger-less follow-up whose content the
character loses stays stored, listed without its action or changes, until it is dismissed; a
becomes one lapses.
The offer list
Section titled “The offer list”offers is what the rules offer the character now, from its file alone, so every
app, a reload and an import see the same list:
- Ephemeral: each stored follow-up, in file order, except those that have lapsed
(Follow-ups), so a file written by hand or by an older engine lists only what
is owed; startAction’s
action.followUpnaming a lapsed one isunknown-follow-up. Its declaration is the one the character has with the samefollowUpandsource. Itsactionis named as when it was stored, even if the action isn’t available now; itsparamsare the stored ones. Achangesconsequence is resolved now, on the current character, withchangethe storedcause, like an action’schangesstep; a change whosewhenis false is left out, and one whose formulas fail leaveschangesempty.causeis the stored one. - Derived: each
whilefollow-up whose condition andwhenhold on the character, whose action is available and whose formulas evaluate, in declaration order. One withcreateis listed only while its slot is empty: the file saves no link for it and the app passes no view. Itsidis the follow-up’s ID, with@<instance ID>for content on a copy; the sameidis listed once.
Each is { id, kind, followUp, source?, to, character?, action?, params?, changes?, create?, required, cause? }:
kind:ephemeralorderived;requiredis the declaration’s (false when absent, or when the character no longer has it).action: startAction’sactioninput ({ id, entry?, source? }, as computeSheet lists the action), withfollowUp: <id>for the character’s own offer;paramswith it.changes: resolved, ready for applyChanges; for the character’s own ephemeral offer they end with{ type: clearFollowUp, id }.- An offer to a link has no
followUpin its action and no clearing change: the app runs it on the linked character and clears it here. create:{ link, start, pack, params }, a new character for an empty slot, with noactionorchanges.linkis the qualified slot;startthe creation start as<pack>:<id>(the ruleset’screationentry with thatpackandid);packthe start’s pack, which the new character uses;paramsthe evaluatedcreate.params, leaving out any that givenull, passed over the start’s own. The app makes a character withcreating: trueandpack, runs the start (guided, or automatic when it israndom), applies its changes and saves the new ID in this character’slinks, which ends the offer. The offer’s ID isn’t startAction’saction.followUp(unknown-follow-up): the start runs on the new character.
A derived offer is never stored and can’t be dismissed: it lasts exactly as long as its condition
holds. “Not now” is the app’s display choice. required is advisory: nothing blocks, it is not a
rules issue, and an ephemeral one can still be dismissed.