From 93e485ebf7f2431aad5fc4f40563c8c5637c46d7 Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:50:06 -0700 Subject: [PATCH 1/6] Engine: loadRuleset reports duplicate-content-id for two items of one pack with one ID The code was published but never emitted: the later item replaced the earlier silently. Signed-off-by: Mitchel Pigsley --- .changeset/duplicate-content-id.md | 7 +++++++ packages/engine/src/core/load.ts | 9 ++++++--- packages/spec/data/error-codes.json | 2 +- .../cases/loads/duplicate-content-id/case.yaml | 17 +++++++++++++++++ .../loads/duplicate-content-id/expected.yaml | 2 ++ 5 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 .changeset/duplicate-content-id.md create mode 100644 packages/spec/tests/cases/loads/duplicate-content-id/case.yaml create mode 100644 packages/spec/tests/cases/loads/duplicate-content-id/expected.yaml diff --git a/.changeset/duplicate-content-id.md b/.changeset/duplicate-content-id.md new file mode 100644 index 000000000..c93de066b --- /dev/null +++ b/.changeset/duplicate-content-id.md @@ -0,0 +1,7 @@ +--- +'@opencharacter/spec': patch +'@opencharacter/engine': patch +--- + +loadRuleset reports `duplicate-content-id` (data `id`, `pack`) when two content items of one +pack share an ID, instead of keeping the last silently. diff --git a/packages/engine/src/core/load.ts b/packages/engine/src/core/load.ts index 9be653a02..48b01c413 100644 --- a/packages/engine/src/core/load.ts +++ b/packages/engine/src/core/load.ts @@ -545,9 +545,12 @@ export function loadPacks(packs: PackRelease[], options: LoadOptions = {}): Load return set; }; for (const pack of byId.values()) { - for (const item of pack.content) - if (item.id !== undefined) - ruleset.content[`${pack.manifest.id}:${item.id}`] = { ...item } as LoadedItem; + for (const item of pack.content) { + if (item.id === undefined) continue; + const qid = `${pack.manifest.id}:${item.id}`; + if (qid in ruleset.content) err('duplicate-content-id', { id: qid, pack: pack.manifest.id }); + else ruleset.content[qid] = { ...item } as LoadedItem; + } } const unknown = (kind: string, ref: string, pack: string) => diff --git a/packages/spec/data/error-codes.json b/packages/spec/data/error-codes.json index 91787e979..b7c5ea09a 100644 --- a/packages/spec/data/error-codes.json +++ b/packages/spec/data/error-codes.json @@ -171,7 +171,7 @@ { "id": "duplicate-content-id", "kind": "load", - "description": "Two items in one pack share an ID." + "description": "Two content items in one pack share an ID: loadRuleset keeps the first. Data: `id` (the qualified ID), `pack`." }, { "id": "unknown-reference", diff --git a/packages/spec/tests/cases/loads/duplicate-content-id/case.yaml b/packages/spec/tests/cases/loads/duplicate-content-id/case.yaml new file mode 100644 index 000000000..480fc9344 --- /dev/null +++ b/packages/spec/tests/cases/loads/duplicate-content-id/case.yaml @@ -0,0 +1,17 @@ +description: "Two content items in one pack share an ID: `duplicate-content-id`" +specVersion: 0.3.0 +call: loadRuleset +packs: + - inline: + manifest: + id: test-pack + version: 1.0.0 + specVersion: 0.3.0 + license: CC0-1.0 + defaultLanguage: en + rules: + contentTypes: + gear: {} + content: + - { id: gear.rope, type: gear } + - { id: gear.rope, type: gear } diff --git a/packages/spec/tests/cases/loads/duplicate-content-id/expected.yaml b/packages/spec/tests/cases/loads/duplicate-content-id/expected.yaml new file mode 100644 index 000000000..99291aab1 --- /dev/null +++ b/packages/spec/tests/cases/loads/duplicate-content-id/expected.yaml @@ -0,0 +1,2 @@ +errors: + - { code: duplicate-content-id } From 21a8cb97afd622222001682187b5fe484fe21f8a Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:52:56 -0700 Subject: [PATCH 2/6] Spec docs: engine-core, engine-interface and upgrades match the engine Aura entries, unknown-situational on an adjust, the pack limit on fields queries, setField, appliers, error kinds, output shapes, pick copies in upgrade steps, derived parameters and added bounds in release checks; the change-type, rules-issue, upgrade-step and error-code descriptions. Signed-off-by: Mitchel Pigsley --- .changeset/spec-docs-engine-algorithms.md | 10 +++ packages/spec/data/change-types.json | 6 +- packages/spec/data/error-codes.json | 4 +- packages/spec/data/rules-issue-codes.json | 4 +- packages/spec/data/upgrade-steps.json | 8 +-- packages/spec/docs/engine-core.md | 65 ++++++++++++----- packages/spec/docs/engine-interface.md | 44 +++++++----- packages/spec/docs/upgrades.md | 88 ++++++++++++++--------- 8 files changed, 147 insertions(+), 82 deletions(-) create mode 100644 .changeset/spec-docs-engine-algorithms.md diff --git a/.changeset/spec-docs-engine-algorithms.md b/.changeset/spec-docs-engine-algorithms.md new file mode 100644 index 000000000..f1332ed17 --- /dev/null +++ b/.changeset/spec-docs-engine-algorithms.md @@ -0,0 +1,10 @@ +--- +'@opencharacter/spec': patch +--- + +engine-core.md, engine-interface.md and upgrades.md catch up with the engine: aura entries and +the `auras` output, `unknown-situational` on an adjust, the pack limit on `fields` queries, +`setField`, `appliers` on every call that takes them, every error kind, the full `choices`, +`offers` and applyChanges output shapes, pick copies in upgrade steps, and derived parameters and +added bounds in release checks. change-types.json, rules-issue-codes.json, upgrade-steps.json and +error-codes.json descriptions match. diff --git a/packages/spec/data/change-types.json b/packages/spec/data/change-types.json index 8788f34c8..a1d4a5ba5 100644 --- a/packages/spec/data/change-types.json +++ b/packages/spec/data/change-types.json @@ -9,7 +9,7 @@ }, { "id": "adjust", - "description": "Adjust a numeric input field by an amount; tags let incoming-change effects (resistance) modify it first." + "description": "Adjust a numeric input field by an amount; tags let incoming-change effects (resistance) modify it first. May carry the receiving player's `situational` choices, the `faces` its incoming-change dice used, and `from`, the character who sent it." }, { "id": "addItem", @@ -53,7 +53,7 @@ }, { "id": "applyEffect", - "description": "Start a temporary effect from content the character does not own (Bless). Without `id`, applyChanges picks one from the source." + "description": "Start a temporary effect from content the character does not own (Bless). Without `id`, applyChanges picks one from the source. May give `appliedBy`, who applied it, and `aura`, one aura of the source: the entry then runs only that aura." }, { "id": "removeEffect", @@ -69,7 +69,7 @@ }, { "id": "unchoose", - "description": "Un-pick an option, removing its nested picks in the same change. Without `option`, every pick of the choice is removed; a choice with none is unchanged. Not on a choice a grant presets (`preset-choice`)." + "description": "Un-pick an option, removing its nested picks in the same change. Without `option`, every pick of the choice is removed; a choice with none is unchanged. Not on a choice a grant presets (`preset-choice`). An action may propose it by owner (`choice: { owner, id }`); apps receive the path form." }, { "id": "setExtension", diff --git a/packages/spec/data/error-codes.json b/packages/spec/data/error-codes.json index b7c5ea09a..e15b9a0f6 100644 --- a/packages/spec/data/error-codes.json +++ b/packages/spec/data/error-codes.json @@ -46,7 +46,7 @@ { "id": "duplicate-id", "kind": "runtime", - "description": "An instance ID is already used in this character; or, in startAction, two participants share a view `id`; or an entry has a standard copy's ID but another item, and is skipped. loadRuleset reports it too when two standard items derive the same instance ID. Data: `id`." + "description": "An instance ID is already used in this character; or, in startAction, two participants share a view `id`; or an entry has a standard copy's ID but another item, and is skipped. In computeSheet and the calls that take `appliers`, two applier views share an `id` (the first is read). loadRuleset reports it too when two standard items derive the same instance ID, when one choice list repeats a choice key (a slot's `-` included), and when a pack adds an inline option whose ID the choice has (data `owner` and `pack` too). Data: `id`." }, { "id": "standard-copy", @@ -176,7 +176,7 @@ { "id": "unknown-reference", "kind": "load", - "description": "Content refers to an ID that does not exist. Also raised when an action runs, for an addItem's item or an applyEffect's source that names no content or a template. A kept template (`keepsTemplates`) that names content other than a template is `unknown-reference` too, with kind `template`. A grant's preset `choices` naming a choice the granted content doesn't declare (or on a `scope: applied` grant) is kind `choice`; a literal preset option it doesn't offer, kind `option` or `content`. A situational choice's `offerFollowUp` naming no rules follow-up, or a derived (`while`) one, is kind `follow-up`." + "description": "Content refers to an ID that does not exist. Also raised when an action runs, for an addItem's item or an applyEffect's source that names no content or a template. A kept template (`keepsTemplates`) that names content other than a template is `unknown-reference` too, with kind `template`. A grant's preset `choices` naming a choice the granted content doesn't declare (or on a `scope: applied` grant) is kind `choice`; a literal preset option it doesn't offer, kind `option` or `content`. A situational choice's `offerFollowUp` naming no rules follow-up, or a derived (`while`) one, is kind `follow-up`. An action's `unchoose` by owner (`choice: { owner, id }`) is kind `choice` when it runs: `owner: self` with no content (a rules action), an owner that names nothing, or a route other than the actor's own." }, { "id": "circular-formula", diff --git a/packages/spec/data/rules-issue-codes.json b/packages/spec/data/rules-issue-codes.json index cfa0e9e85..1df4c7851 100644 --- a/packages/spec/data/rules-issue-codes.json +++ b/packages/spec/data/rules-issue-codes.json @@ -1,7 +1,7 @@ { "set": "rules-issue-codes", "specVersion": "0.1.0", - "description": "Spec-level rules issue codes. Pack-defined prerequisites and constraints are reported under these codes with the pack's qualified ID.", + "description": "Spec-level rules issue codes. Pack-defined prerequisites and constraints are reported under these codes with the pack's qualified ID. Choice issues carry `/`, with `:rules` as the owner of a rules-level choice.", "items": [ { "id": "prerequisite-not-met", @@ -29,7 +29,7 @@ }, { "id": "option-not-eligible", - "description": "A picked option no longer matches the choice's query." + "description": "A picked option no longer fits its choice: its query, its `where`, its `values` formula or its `eligible`." }, { "id": "content-hidden", diff --git a/packages/spec/data/upgrade-steps.json b/packages/spec/data/upgrade-steps.json index 2a3d650a8..c6991a305 100644 --- a/packages/spec/data/upgrade-steps.json +++ b/packages/spec/data/upgrade-steps.json @@ -9,7 +9,7 @@ }, { "id": "removeContent", - "description": "Remove copies of, picks for and the saved grant state (`grants`) of content `id`." + "description": "Remove copies of, picks for and the saved grant state (`grants`) of content `id`. A removed pick's copy (`addAs: copy`) goes too." }, { "id": "changeContentType", @@ -57,11 +57,11 @@ }, { "id": "removeChoice", - "description": "Remove the picks of choice `choice` on `owner` (or the rules), with their nested picks." + "description": "Remove the picks of choice `choice` on `owner` (or the rules), with their nested picks and their copies (`addAs: copy`)." }, { "id": "renameOption", - "description": "Replace a picked option `from` of choice `choice` (on `owner`, or the rules) with `to`, or remove the pick when `to` is absent." + "description": "Replace a picked option `from` of choice `choice` (on `owner`, or the rules) with `to`, or remove the pick (with its copy, `addAs: copy`) when `to` is absent." }, { "id": "requireChoice", @@ -93,7 +93,7 @@ }, { "id": "adoptStandard", - "description": "Rename the first entry holding `item` to the item's standard copy ID, unless an entry has it already." + "description": "Rename the first entry holding `item`, not one a pick holds as its copy, to the item's standard copy ID, unless an entry has it already." } ] } diff --git a/packages/spec/docs/engine-core.md b/packages/spec/docs/engine-core.md index 7d2a23e46..2b38e904a 100644 --- a/packages/spec/docs/engine-core.md +++ b/packages/spec/docs/engine-core.md @@ -59,7 +59,8 @@ Input: pack releases in load order (later packs win). Output: a ruleset, or erro type name, field ID and action ID in the ruleset is qualified. This includes the values of `id`, `set` and `list` item fields of a content type or of `field`, 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. + for template fields resolve in the template's pack. Two items of one pack with the same ID + are `duplicate-content-id` (data: `id`, the qualified ID, and `pack`). - **`extends`:** start from the parent item (itself resolved first; `circular-extends` on a loop, `maxExtendsDepth` at most). Then apply the item's own properties: scalars and objects key by key, arrays (`tags`, `effects`, `choices`, …) replaced whole. @@ -311,7 +312,7 @@ and its scope (`holder` or `applied`): | 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 of an applicable choice¹ | the owner of the choice | holder | -| An `applied` entry with `active` not `false` | none (`self` is the applied entry: `id` and `state`) | applied | +| 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 | @@ -325,6 +326,9 @@ inactive in turn, but none of their picks is active either). (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 entry with `aura` activates only that aura's `effects` and `immuneTo`, none of its source's +effects, grants, actions or immunities ([content.md](content.md#auras)). + 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. @@ -420,7 +424,8 @@ blocked entry is reported as `immune`, once per matching immunity **Effects on a set of fields** ([effects.md](effects.md#targets)). 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 +field order, among the fields of packs whose scopes share a pack with the effect's +([effects.md](effects.md#which-packs-a-tag-target-reaches)), 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 `<…>@`), 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 @@ -640,14 +645,13 @@ For each activation's choices, and the rules-level choices: - `option-not-eligible`, for a picked option that no longer matches its choice's query, and for a picked value that fails its choice's `where` or that its formula no longer lists, and for a pick that fails its choice's `eligible`. -- `content-hidden` covers hidden content picked in a choice with `activate: false` too; it - belongs to the choice's owner. -- `content-hidden`, for owned or picked hidden content. +- `content-hidden`, for owned or picked hidden content, a pick of a choice with + `activate: false` included (it belongs to the choice's owner). - `immune`, for each blocked `applied` entry and each active immunity its source matches: the holder's, then the active candidates' in `applied` order ([Activation](#activation)). - `id` is 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`). + (`5e-srd-5.1:listed`); for an aura's, `#/`. - `source` is the immunity's activation (for the rules', `{ type: rules, id }` with the same `id`). - `data` is `{ entry, item }`: the applied entry's ID and its source. @@ -724,9 +728,11 @@ one, its fixed `params` included. An available action with 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 `#`. -**Tags:** each listing carries the action's tags, its `{ formula }` entries evaluated with the +**Tags:** each listing has the action's tags, its `{ formula }` entries evaluated with the listing's `item` and `self` and the character's fields (only the written tags when one fails), so one shared action is tagged per item: display groups and `params` effects match these. +computeSheet's `actions` doesn't output them; describeAction does +([engine-interface.md](engine-interface.md#describeaction)). **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 @@ -763,8 +769,11 @@ index in `activations`, in place of `entry`. `copies` maps each copy that has any computed values to `{ : value }`, and `activationValues` lists each such activation that has any as `{ activation, values }`, in activation order. The same values are `self.` in the copy's (or the content's) actions and -follow-ups ([Copies and `self`](#copies-and-self)); picked or granted content's actions see its -owning copy's values with its own over them by name. An engine may compute them on demand, with +follow-ups ([Copies and `self`](#copies-and-self)), and in its effects that act as an action +runs (on rolls, choice and changes steps, listed actions and incoming changes; +[content.md](content.md#computed-values)); picked or granted content's see its owning copy's +values with its own over them by name. Field effects, applied and aura effects, and every +formula that feeds a field never read them. An engine may compute them on demand, with the same result. `copyNames` maps each copy whose item or content type has `nameFrom` @@ -880,7 +889,7 @@ ruleset order (packs in load order, then declaration order), after activation: item is unknown counts for no type. - **`copyLimits`** has one entry per type: `{ type, max, perItem, count, entries }`, with `type` qualified, `max` and `perItem` as declared or, for a formula, its value on the - computed sheet (absent when not declared, or when the formula fails: a `formula-error` with + computed sheet (absent when not declared, or when the formula fails: the formula's error, with `where: ' copies '`), `count` the number of copies and `entries` each copy as `{ entry, item }`. - **`too-many-copies`** when `count` exceeds `max`: `id` the qualified type, `source` @@ -940,18 +949,38 @@ rules files in path order). A condition that fails to evaluate counts as false. choices for rolls and for incoming changes alike; an app offers a choice for a change when its `incoming` matches the change. +### Auras + +`auras` lists the auras the character projects now ([content.md](content.md#auras)), in +activation order, each `{ source, aura, entry?, size?, affects }`: each holder aura of a holder +activation whose item's `effectsWhen` and the aura's `when` hold, and each `scope: applied` aura +of an active applied entry the character applied itself (no `appliedBy`, or its own `id`), once +per entry. Content a grant's `available` holds back projects none. `size` is evaluated with the +activation's `item` and `self`; a value that isn't a number leaves it out. A `when` or `size` +that fails leaves the aura out, with the formula's error (`where: '# when'` or +`size`). + ### 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: +(runtime error codes, and `formula-error` for a formula that fails on this sheet), and the sheet +is computed without the offending part: -- An entry whose item is unknown (`unknown-id`) is skipped. +- An entry whose item is unknown, or an applied entry whose `aura` its source doesn't declare + (`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`). +- A computed value whose formula fails is left out (`formula-error`, with `entry` for a copy's + value or `activation` for picked or granted content's). +- An aura whose `when` or `size` fails is left out of `auras`, with the formula's error + (`where: '# when'` or `size`; [Auras](#auras)). +- A `max` or `perItem` formula of a content type that fails leaves the bound out of + `copyLimits`, with the formula's error (`where: ' copies '`). +- An applier view's field of the wrong type reads the declared default (`invalid-link-value`, + data `applier`); of two views with one `id`, the first is read (`duplicate-id`). - An entry with a standard copy's ID and another item is skipped (`duplicate-id`); the standard copy is unsaved. ## listOptions @@ -1035,7 +1064,7 @@ successful result returns `changes`: the batch as applied, with the faces it rol | `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) | +| `applyEffect` | ID unused when an entry is added (`duplicate-id`; without one, generated: below), source is content and not a template (`unknown-id`), `aura`, when given, an aura the source declares (`unknown-id`, data `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`) no grant presets (`preset-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; for an `addAs: copy` choice, first adds its copy as `addItem` would, under the option's derived ID plus `_pick` (`_pick-2`, … when taken), and saves that ID as the pick's `entry` | | `unchoose` | the path leads to a choice (`unknown-choice`) no grant presets (`preset-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. Copies the removed picks held go too | @@ -1087,7 +1116,8 @@ match, the entry is added as usual. Replaying the batch gives the same result, s depends only on the character and the change. A merged entry takes the change's `appliedBy`, when it gives one, so the latest applier owns it; `match: [appliedBy]` compares the entries' appliers instead of a state key, so each applier keeps an entry of its own. An added entry saves the -change's `appliedBy`. +change's `appliedBy`. An entry with `aura` merges only into an entry of the same aura and the +same `appliedBy`, so each applier's aura is an entry of its own. **Changes aimed at copies**. `setState`, `adjustState`, `setField`, `setQuantity`, `adjustQuantity`, `equip` and `removeItem` may give `copies: { type, where }` @@ -1273,7 +1303,8 @@ An `adjust` may carry `situational`: the receiving player's [situational choices](rules-and-fields.md#choices-on-incoming-changes) 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 is `wrong-type`. +- An ID the ruleset doesn't declare is `unknown-situational` (data: `situational`, the ID as + given); a value of the wrong type for the choice is `wrong-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 an `incoming` that matches the change (its diff --git a/packages/spec/docs/engine-interface.md b/packages/spec/docs/engine-interface.md index 223799d28..0e8404980 100644 --- a/packages/spec/docs/engine-interface.md +++ b/packages/spec/docs/engine-interface.md @@ -39,16 +39,16 @@ The spec leaves these to the app. Packs give the app what it needs for each: an ## The calls -| Call | Input | Output | Schema | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| `loadRuleset` | pack releases in load order | `{ ok: true, ruleset }` or `{ ok: false, errors }` | [load-ruleset](../schemas/engine/load-ruleset.schema.json) | -| `computeSheet` | ruleset, character, optional competing-option picks (a list of option IDs), optional `links` | values with breakdowns, active effects, available actions, offered situational choices, pending choices, offers, competing options, rules issues, computed values per copy and per picked or granted content | [compute-sheet](../schemas/engine/compute-sheet.schema.json) | -| `listOptions` | ruleset, character, a choice path, optional `links` | options, each with `eligible`, `picked`, `recommended` and `reasons` | [list-options](../schemas/engine/list-options.schema.json) | -| `describeAction` | ruleset, character, action (`id`, `entry?`, `source?`), optional `links` | the action's defining ID, tags, results, parameters (each with `required`, bounds, enum values and the IDs a reference parameter takes) and roles | [describe-action](../schemas/engine/describe-action.schema.json) | -| `applyChanges` | ruleset, character, changes, `strict?`, optional `links`, optional dice function | `{ ok: true, character, changes, issues, observe? }` or `{ ok: false, errors }` | [apply-changes](../schemas/engine/apply-changes.schema.json) | -| `startAction` | ruleset, character, action (`id`, `entry?`), params, situational, policy, `links?`, `participants?`, `answers?`, dice function | finished, paused, or failure | [start-action](../schemas/engine/start-action.schema.json) | -| `continueAction` | ruleset, paused result, answer (`faces`, `option` or a handoff's `outcome`), policy | finished, paused again, or failure | [continue-action](../schemas/engine/continue-action.schema.json) | -| `upgrade` | old ruleset, new ruleset, character | upgraded character, pending choices, "what changed" report | [upgrade](../schemas/engine/upgrade.schema.json) | +| Call | Input | Output | Schema | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| `loadRuleset` | pack releases in load order | `{ ok: true, ruleset }` or `{ ok: false, errors }` | [load-ruleset](../schemas/engine/load-ruleset.schema.json) | +| `computeSheet` | ruleset, character, optional competing-option picks (a list of option IDs), optional `links` and `appliers` | values with breakdowns, active effects, available actions, offered situational choices, pending choices, offers, competing options, rules issues, computed values per copy and per picked or granted content | [compute-sheet](../schemas/engine/compute-sheet.schema.json) | +| `listOptions` | ruleset, character, a choice path, optional `links` and `appliers` | options, each with `eligible`, `picked`, `recommended` and `reasons` | [list-options](../schemas/engine/list-options.schema.json) | +| `describeAction` | ruleset, character, action (`id`, `entry?`, `source?`), optional `links` and `appliers` | the action's defining ID, tags, results, parameters (each with `required`, bounds, enum values and the IDs a reference parameter takes) and roles | [describe-action](../schemas/engine/describe-action.schema.json) | +| `applyChanges` | ruleset, character, changes, `strict?`, optional `links` and `appliers`, optional dice function | `{ ok: true, character, changes, adjustments, issues, followUps, observe? }` or `{ ok: false, errors, issues? }` | [apply-changes](../schemas/engine/apply-changes.schema.json) | +| `startAction` | ruleset, character, action (`id`, `entry?`), params, situational, policy, `links?`, `appliers?`, `participants?`, `answers?`, dice function | finished, paused, or failure | [start-action](../schemas/engine/start-action.schema.json) | +| `continueAction` | ruleset, paused result, answer (`faces`, `option` or a handoff's `outcome`), policy | finished, paused again, or failure | [continue-action](../schemas/engine/continue-action.schema.json) | +| `upgrade` | old ruleset, new ruleset, character | upgraded character, pending choices, "what changed" report | [upgrade](../schemas/engine/upgrade.schema.json) | The ruleset's shape ([ruleset.schema.json](../schemas/engine/ruleset.schema.json)) is **PROVISIONAL**: only its top-level properties are fixed (`specVersion`, `packs`, `fields`, `contentTypes`, `content`, `rules`, `conflicts`). It is plain data, so apps can cache it or send it between machines running the same engine; another engine should rebuild it from the pack releases. @@ -74,17 +74,19 @@ Besides these calls, `checkRelease` ([upgrades.md](upgrades.md#the-build-check), - `offers` lists what the rules offer the character now: its stored ephemeral follow-ups (a concentration check after damage), then its derived offers (a level-up the experience has earned). Each is `{ id, kind, followUp, source?, to, character?, action?, - params?, changes?, required, cause? }`; an app runs one with startAction and its `action` + params?, changes?, create?, required, cause? }`; an app runs one with startAction and its `action` (which carries `followUp: `), and dismisses only ephemeral ones ([engine-core.md](engine-core.md#the-offer-list)). - `choices` lists every applicable choice, answered or not, in pending order, - as `{ path, owner, id, slot?, min, max?, picks, open, preset? }`, inline options' and + as `{ path, owner, id, slot?, type?, min, max?, picks, open, preset?, inactive? }`, inline options' and `activate: false` choices included; `preset: true` marks a choice a grant answers, which - `choose` and `unchoose` refuse (`preset-choice`) ([engine-core.md](engine-core.md#the-choice-list)). + `choose` and `unchoose` refuse (`preset-choice`), and `inactive: true` one whose `activeWhen` + fails ([engine-core.md](engine-core.md#the-choice-list)). - `competing` lists competing options, one entry per competing group with its `op` and, for a stacking group, `stackKey` (see [effects.md](effects.md#competing-options)); a group of a key spanning fields also lists `fields`, and each option its `values` per field. The input's `competing` is a list of picked option IDs; each group applies its listed option, else its recommendation. - `auras` lists the auras the character projects now (`{ source, aura, entry?, size?, affects }`): the app applies an entry of each, `appliedBy` the character, to whoever is inside, and removes - it as they leave or the aura drops off this list ([content.md](content.md#auras)). `entry` is + it as they leave or the aura drops off this list ([content.md](content.md#auras), + [engine-core.md](engine-core.md#auras)). `entry` is the copy a held aura's content belongs to, or the applied entry that projects a cast one (`scope: applied`), so two casts are two listings; a cast's aura leaves the list when its entry ends. @@ -259,14 +261,14 @@ value outside it (`invalid-option`). A runtime error means the result would not be a valid character file. A rules issue means the file is valid, but the game's rules object. -- **Errors** ([`error.schema.json`](../schemas/engine/error.schema.json)): `{ code, path?, data? }`, where `code` comes from [`data/error-codes.json`](../data/error-codes.json). Each code has a `kind`: `shape`, `runtime`, `load` or `action`. -- **Rules issues** ([`rules-issue.schema.json`](../schemas/engine/rules-issue.schema.json)): `{ code, id?, source?, data? }`, where `code` comes from [`data/rules-issue-codes.json`](../data/rules-issue-codes.json). `id` is the qualified pack-defined prerequisite or constraint ID. +- **Errors** ([`error.schema.json`](../schemas/engine/error.schema.json)): `{ code, path?, data? }`, where `code` comes from [`data/error-codes.json`](../data/error-codes.json). Each code has a `kind`: `shape`, `runtime`, `load`, `action`, `formula` (a formula's own error, usually the cause of a `formula-error`) or `build` (checkRelease). +- **Rules issues** ([`rules-issue.schema.json`](../schemas/engine/rules-issue.schema.json)): `{ code, id?, source?, data? }`, where `code` comes from [`data/rules-issue-codes.json`](../data/rules-issue-codes.json). `id` names what the issue is about: the qualified prerequisite or constraint, the choice (`/`, `:rules/` at rules level), the immunity, the field or the content type ([engine-core.md](engine-core.md#pending-choices-and-issues)). - **Strict mode** (`strict: true` on `applyChanges`) turns rules issues into a runtime error (`rules-issue`, with the issues attached). Rules issues don't block by default because homebrew tables bend rules on purpose. - The spec ships standard English text for every code in [`../i18n/en.yaml`](../i18n/en.yaml) (`errors.`, `issues.`), exported as `@opencharacter/spec/i18n/en` (JavaScript and JSON; [i18n.md](i18n.md#engine-output)). ## Changes -The change types are a small, generic, fixed set ([data](../data/change-types.json)): `set`, `adjust`, `addItem`, `removeItem`, `equip`, `setState`, `adjustState`, `setQuantity`, `adjustQuantity`, `applyTemplate`, `removeTemplate`, `applyEffect`, `removeEffect`, `toggleEffect`, `choose`, `unchoose`, `setExtension`, `finishCreation`, `setHomebrew`, `dismissFollowUp`, `clearFollowUp`, `offerFollowUp` and `observe`. +The change types are a small, generic, fixed set ([data](../data/change-types.json)): `set`, `adjust`, `addItem`, `removeItem`, `equip`, `setState`, `adjustState`, `setField`, `setQuantity`, `adjustQuantity`, `applyTemplate`, `removeTemplate`, `applyEffect`, `removeEffect`, `toggleEffect`, `choose`, `unchoose`, `setExtension`, `finishCreation`, `setHomebrew`, `dismissFollowUp`, `clearFollowUp`, `offerFollowUp` and `observe`. - **Batches are all or nothing.** A level-up of eight changes either fully applies or doesn't apply at all. - **Same changes, same result.** Replaying a character's change list from the start always rebuilds the same character, which gives apps undo, history and multiplayer sync. @@ -275,15 +277,19 @@ The change types are a small, generic, fixed set ([data](../data/change-types.js - An `on` or `becomes` follow-up the batch triggers (a concentration check after damage, a death save at maximum wounds) is stored in the returned character's `followUps`, and the output's `followUps` lists the ones this batch stored, as computeSheet's `offers` does. applyChanges never runs or applies them; the app decides ([engine-core.md](engine-core.md#follow-ups)). `dismissFollowUp` deletes one unrun; `clearFollowUp`, which startAction proposes for a run that takes one up, deletes it when the run's changes are applied. - `unchoose` removes the option's nested picks in the same change. Without `option` it removes every pick of the choice, with their nested picks; a choice with none is left unchanged, so a batch can - clear a choice and pick again without knowing what it held. + clear a choice and pick again without knowing what it held. An action may name the choice by + its owner (`choice: { owner, id }`); the engine proposes the path form, one per activation + with picks ([dice-and-actions.md](dice-and-actions.md#actions)). - `finishCreation` clears the character's `creating`. It is rejected with `creation-incomplete` while the character has rules issues or pending choices, which the failed result lists as `issues` (pending choices as `too-few-picks`). - `setHomebrew` replaces the character's own homebrew, or removes it with `null`; later changes in the batch see the new one ([engine-core.md](engine-core.md#applychanges)). Actions can't propose it. +- `setField` sets a copy's own value of an item field its content type marks `perCopy`, or + removes it with `null` ([content.md](content.md#per-copy-fields)). - `setQuantity` and `adjustQuantity` change a copy's `quantity` (spending an arrow, buying ten more). It never goes below 0, and a copy at 0 stays: removing it is a separate `removeItem`. -- `setState`, `adjustState`, `setQuantity`, `adjustQuantity`, `equip` and `removeItem` may name their copies with a query (`copies: { type, where }`) instead of one `entry`. The receiving character resolves it, so an action can aim one at its target's copies ([engine-core.md](engine-core.md#applychanges)). +- `setState`, `adjustState`, `setField`, `setQuantity`, `adjustQuantity`, `equip` and `removeItem` may name their copies with a query (`copies: { type, where }`) instead of one `entry`. The receiving character resolves it, so an action can aim one at its target's copies ([engine-core.md](engine-core.md#applychanges)). - `adjust` may name who sent it (`from`, a character ID; actions and follow-ups fill it in for another character), which its field entries keep for `on` follow-ups (`change.from`) and `observe` ([engine-core.md](engine-core.md#who-sent-it)). diff --git a/packages/spec/docs/upgrades.md b/packages/spec/docs/upgrades.md index a5f481bcc..e33a51044 100644 --- a/packages/spec/docs/upgrades.md +++ b/packages/spec/docs/upgrades.md @@ -10,18 +10,21 @@ item values just recalculate. A version that changes the **shape** of saved stat Between two releases of a pack, these differences are **shape changes**, because a saved character could refer to something that no longer means the same: -| Change | `kind` | Covered by | -| -------------------------------------------------------------------------------- | ---------------------- | ----------------------------------------------------------- | -| A content item is removed | `content-removed` | `renameContent` or `removeContent` | -| An item's content type changes | `content-type-changed` | `changeContentType` or `removeContent` | -| An input field is removed, or stops being an input | `field-removed` | `renameField` or `removeField` | -| An input field's type changes, or an enum loses values | `field-type-changed` | `mapValues` or `removeField` | -| A state key is removed or its type changes (on a content type, item or template) | `state-removed` | `renameState`, `removeState`, `moveState` or `stateToField` | -| A choice is removed | `choice-removed` | `renameChoice` or `removeChoice` | -| An option is removed from a `values` or inline-options choice | `option-removed` | `renameOption` | -| A new required choice appears on existing content or in the rules | `choice-added` | `requireChoice` | -| An existing item becomes standard (a standard copies query now matches it) | `standard-copy-added` | `adoptStandard` | -| A remaining item stops granting content it granted | `grant-removed` | `grantsToCopies` or `removeContent` | +| Change | `kind` | Covered by | +| -------------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------ | +| A content item is removed | `content-removed` | `renameContent` or `removeContent` | +| An item's content type changes | `content-type-changed` | `changeContentType` or `removeContent` | +| An input field is removed, or stops being an input | `field-removed` | `renameField` or `removeField` | +| An input field's type changes, or an enum loses values | `field-type-changed` | `mapValues` or `removeField` | +| A state key is removed or its type changes (on a content type, item or template) | `state-removed` | `renameState`, `removeState`, `moveState` or `stateToField`¹ | +| A choice is removed | `choice-removed` | `renameChoice` or `removeChoice` | +| An option is removed from a `values` or inline-options choice | `option-removed` | `renameOption` | +| A new required choice appears on existing content or in the rules | `choice-added` | `requireChoice` | +| An existing item becomes standard (a standard copies query now matches it) | `standard-copy-added` | `adoptStandard` | +| A remaining item stops granting content it granted | `grant-removed` | `grantsToCopies` or `removeContent` | + +¹ A state step's `type` may be the content type's new name when the script renames it +(`renameContentType`). `standard-copy-added` is for an item of the pack, in both releases with the same type, that the standard copies queries ([content.md](content.md#standard-copies)) match in the @@ -62,6 +65,9 @@ Deprecating an item (it stays, marked `deprecated`) is not a shape change. (`:classes.operative/specialization/ghost/skill-feat`); - removed options: `/