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/.changeset/spec-docs-actions.md b/.changeset/spec-docs-actions.md new file mode 100644 index 000000000..fbee1d7ec --- /dev/null +++ b/.changeset/spec-docs-actions.md @@ -0,0 +1,9 @@ +--- +'@opencharacter/spec': patch +--- + +dice-and-actions.md and engine-actions.md catch up with the engine: `to: applied` routing, the +reaction route through situational choices, every action and roll part property, the full dice +notation, every safety limit and every error startAction can return. The Fate group example is +valid, and Healing Grace bounds `spend`. dice-modifiers.json gives every notation form and +`rerollOnce`'s `count`. diff --git a/.changeset/spec-docs-effects-content.md b/.changeset/spec-docs-effects-content.md new file mode 100644 index 000000000..26c85b8ff --- /dev/null +++ b/.changeset/spec-docs-effects-content.md @@ -0,0 +1,10 @@ +--- +'@opencharacter/spec': patch +--- + +effects.md, content.md, characters.md and display-hints.md catch up with the format: the full +ops and property tables (`params`, `scope`, the `changes` and `option` value shapes), examples +that match the packs (Null Field, `effectsWhen` gates, `appliedBy: self`), every content item and +choice property in the tables, and the once-per-attack route for copy-state costs. +effect-operations.json, stacking-policies.json and the character schema's `applied.aura` +descriptions match. 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/.changeset/spec-docs-formulas-packs-i18n.md b/.changeset/spec-docs-formulas-packs-i18n.md new file mode 100644 index 000000000..fb19d6309 --- /dev/null +++ b/.changeset/spec-docs-formulas-packs-i18n.md @@ -0,0 +1,10 @@ +--- +'@opencharacter/spec': patch +--- + +formulas.md gives every root where it is bound and typed (`appliedBy`, `applier`, `grant`, `i`, +`changes` and `action` effects, derived parameters, a situational choice's copy changes) and +every place's expected type. i18n.md and the en.yaml header list the `invalid-added-param` +reasons and how an added parameter is named. packs.md describes `LicenseRef-` licenses. The +README, rules-and-fields.md and versioning.md are brought up to date, and formula-context.json +and safety-limits.json descriptions match. 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/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/dice-modifiers.json b/packages/spec/data/dice-modifiers.json index 4186c9dc9..63cac55a7 100644 --- a/packages/spec/data/dice-modifiers.json +++ b/packages/spec/data/dice-modifiers.json @@ -52,17 +52,17 @@ "id": "rerollOnce", "notation": "roN or ro<=N", "example": "2d6ro<=2", - "description": "Reroll dice matching the comparison once." + "description": "Reroll dice matching the comparison once. From a `reroll` roll modifier with `count`, it also carries `count`: at most that many dice are rerolled." }, { "id": "countSuccesses", - "notation": "csN, >=N", + "notation": "csN or cs>=N", "example": "5d6cs>=5", "description": "The result is the number of dice matching the comparison instead of their sum." }, { "id": "countFailures", - "notation": "cfN", + "notation": "cfN or cf<=N", "example": "5d10cs>=8cf1", "description": "Subtract one for each die matching the comparison (used with countSuccesses)." }, diff --git a/packages/spec/data/effect-operations.json b/packages/spec/data/effect-operations.json index 5fc859bcd..b8b536d84 100644 --- a/packages/spec/data/effect-operations.json +++ b/packages/spec/data/effect-operations.json @@ -1,7 +1,7 @@ { "set": "effect-operations", "specVersion": "0.1.0", - "description": "The fixed set of effect operations. Per field, effects apply in `order` (lower first); atLeast and atMost share the last stage.", + "description": "The fixed set of effect operations. Per field, effects apply in `order` (lower first; atLeast and atMost share a stage). `order` 0 is no field stage: grant comes with the field's own value, and the tag, choice, step and action ops have their own timing (effects.md).", "items": [ { "id": "set", @@ -55,7 +55,7 @@ "id": "option", "order": 0, "targets": ["choice"], - "description": "Add an option to an action's choice steps with a matching tag: `{ id, changes, to? }`, offered after the step's own options while the effect's `when` holds, with ID `#` (`:rules#` for the rules' effects)." + "description": "Add an option to an action's choice steps with a matching tag: `{ id, changes?, to?, flags? }`, offered after the step's own options while the effect's `when` holds, with ID `#` (`:rules#` for the rules' effects)." }, { "id": "changes", @@ -73,7 +73,7 @@ "id": "params", "order": 0, "targets": ["action"], - "description": "Add parameters to listed actions with a matching tag: `{ : }`, each optional or defaulted, after the action's own on the run the app starts while the effect's `when` holds. A name an action the literal tags reach declares, or another effect's other declaration of it, is a load error; through computed tags, the action's own and then the first effect's win." + "description": "Add parameters to listed actions with a matching tag: `{ : }`, each optional, defaulted or derived (`formula`), after the action's own on the run the app starts while the effect's `when` holds. Their `min` and `max` may read the action's `params`. An entry with only `min` and/or `max`, naming a parameter the run already has, adds bounds to it instead. A name an action the literal tags reach declares, or another effect's other declaration of it (not between `from: self` effects of different content items that no item holding both has an action both reach), is a load error; through computed tags, the action's own and then the first effect's win." } ] } diff --git a/packages/spec/data/error-codes.json b/packages/spec/data/error-codes.json index 91787e979..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", @@ -171,12 +171,12 @@ { "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", "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/formula-context.json b/packages/spec/data/formula-context.json index 860d20cd1..b36585b3d 100644 --- a/packages/spec/data/formula-context.json +++ b/packages/spec/data/formula-context.json @@ -17,7 +17,7 @@ }, { "id": "self", - "description": "The character's copy of the item carrying the formula: `self.equipped`, `self.`, `self.templates` (saved, then those content keeps on the copy). For content made active by a pick or a grant, the copy that owns the choice or grant (a subclass sees its class copy's `self.level`); picked or held granted content that declares state keys reads them from its pick, or its grant's saved state (else the grant's `state`), with declared defaults, over that copy. Absent for content with no owning copy (picked in a rules-level choice). In a choice step over copies (`where`, `prefer`), the candidate copy; not bound in one over content. In an applied entry's effects and its actions (`scope: applied`), the entry: `id`, `appliedBy` (the ID of the character who applied it, `''` when the file names none) and its state keys. In a change aimed at copies (`copies.where`, a per-copy `by` or `value`, a situational choice's `setState` and `adjustState` included), the matched copy. In a change aimed at applied entries (`applied.where`, a per-entry `by` or `value`), the entry: `id`, `appliedBy` and its state keys with declared defaults. In the copy's own actions (and actions it reaches through `use`) and its follow-ups, `self` also has the copy's computed values, `self.`; in a computed value, those declared before it; everywhere else a computed name is `unknown-name`. In a content display group's `where`, `sectionBy` and `sortBy` and a content-type state hint's `showWhen`, the member copy, absent for picked content with no owning copy. In a `keepsTemplates` query that reads the keeper, and in its `params`, the keeper's. The copy's choices aren't on `self`: `picksOf()` reads them." + "description": "The character's copy of the item carrying the formula: `self.equipped`, `self.`, `self.templates` (saved, then those content keeps on the copy). For content made active by a pick or a grant, the copy that owns the choice or grant (a subclass sees its class copy's `self.level`); picked or held granted content that declares state keys reads them from its pick, or its grant's saved state (else the grant's `state`), with declared defaults, over that copy. Absent for content with no owning copy (picked in a rules-level choice). In a choice step over copies (`where`, `prefer`), the candidate copy; not bound in one over content. In an applied entry's effects and its actions (`scope: applied`), the entry: `id`, `appliedBy` (the ID of the character who applied it, `''` when the file names none) and its state keys. In a change aimed at copies (`copies.where`, a per-copy `by` or `value`, a situational choice's `setState` and `adjustState` included), the matched copy. In a change aimed at applied entries (`applied.where`, a per-entry `by` or `value`), the entry: `id`, `appliedBy` and its state keys with declared defaults. In the copy's own actions (and actions it reaches through `use`) and its follow-ups, and in its effects that act as an action runs (on rolls, choice and changes steps, listed actions and incoming changes; not an applied or aura effect), `self` also has the copy's computed values, `self.`; in a computed value, those declared before it; everywhere else a computed name is `unknown-name`. In a content display group's `where`, `sectionBy` and `sortBy` and a content-type state hint's `showWhen`, the member copy, absent for picked content with no owning copy. In a `keepsTemplates` query that reads the keeper, and in its `params`, the keeper's. The copy's choices aren't on `self`: `picksOf()` reads them." }, { "id": "pick", @@ -41,7 +41,7 @@ }, { "id": "params", - "description": "In a template's item field values: the template's `params`, as the content keeping it gave them (`keepsTemplates` `params`), each declared default where none was given. In an action: parameters the app supplied. Also in a parameter's `min` and `max` (an action's own, or one a `params` effect adds), evaluated once every parameter has its value. A field parameter (`type: id, of: field`) is `{ id, type, tags, value }`: the field's qualified ID, type, tags on the sheet (written and computed; `[]` if none) and computed value. Substituted as literals in a formula a proposed change passes on." + "description": "In a template's item field values: the template's `params`, as the content keeping it gave them (`keepsTemplates` `params`), each declared default where none was given. In an action: parameters the app supplied, and derived ones (a declaration's `formula`), worked out as the action starts; a derived parameter's `formula` reads only those declared before it. Also in a parameter's `min` and `max` (an action's own, one a `params` effect adds, or a bound a `params` effect adds to one), evaluated once every parameter has its value. A field parameter (`type: id, of: field`) is `{ id, type, tags, value }`: the field's qualified ID, type, tags on the sheet (written and computed; `[]` if none) and computed value. Substituted as literals in a formula a proposed change passes on." }, { "id": "participants", 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/safety-limits.json b/packages/spec/data/safety-limits.json index 870c99046..9f3ebbe58 100644 --- a/packages/spec/data/safety-limits.json +++ b/packages/spec/data/safety-limits.json @@ -36,12 +36,12 @@ { "id": "maxActionSteps", "value": 1000, - "description": "Steps one action may run, including nested actions." + "description": "Steps one action may run, including nested actions (`action` steps and `action` effects)." }, { "id": "maxActionDepth", "value": 16, - "description": "Nesting depth of actions running actions." + "description": "Nesting depth of actions running actions (`action` steps and `action` effects)." }, { "id": "maxGroupPasses", diff --git a/packages/spec/data/stacking-policies.json b/packages/spec/data/stacking-policies.json index 2c908cf50..9ab332c92 100644 --- a/packages/spec/data/stacking-policies.json +++ b/packages/spec/data/stacking-policies.json @@ -1,7 +1,7 @@ { "set": "stacking-policies", "specVersion": "0.1.0", - "description": "How effects sharing a stack key combine on the same target.", + "description": "How effects sharing a stack key combine on the same target, or across fields for a key that spans them (`spansFields`; every policy but `addAll`).", "items": [ { "id": "addAll", 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/README.md b/packages/spec/docs/README.md index d511c060b..91bbf38cc 100644 --- a/packages/spec/docs/README.md +++ b/packages/spec/docs/README.md @@ -1,6 +1,6 @@ # Open Character Format: specification -**Spec version 0.3.0.** The shapes are in place and were checked against three hand-modeled games. The formula language and engine behavior (the core calls, actions and upgrades) are specified, and the shared test suite covers every call. Parts that are not final are marked **PROVISIONAL** or **PLACEHOLDER**. +**Spec version 0.3.0.** The shapes are in place and are exercised by the official packs of a dozen games. The formula language and engine behavior (the core calls, actions and upgrades) are specified, and the shared test suite covers every call. Parts that are not final are marked **PROVISIONAL** or **PLACEHOLDER**. OCF is an open format for tabletop RPG character data. The spec covers three layers: @@ -14,27 +14,27 @@ Core principle: **the engine knows the rules; the app owns the experience.** The ## Document map -| Document | Covers | -| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | -| [packs.md](packs.md) | Pack layout, manifest, IDs, dependencies, overrides, licensing metadata | -| [rules-and-fields.md](rules-and-fields.md) | Fields, settings (rounding, stacking, competing), constraints, result bands, situational choices, link slots, creation starts | -| [content.md](content.md) | Content types, items, per-copy state, templates, `extends`, queries, grants, prerequisites, choices | -| [effects.md](effects.md) | Effect targets, operations and their order, competition, stacking, rounding, loops | -| [formulas.md](formulas.md) | The formula language: grammar, tree, types, functions, dice, errors | -| [dice-and-actions.md](dice-and-actions.md) | Dice notation and modifiers, roll items, actions, checkpoints, logs, safety limits | -| [characters.md](characters.md) | The character file, a character's own homebrew, creating a character, and exports | -| [engine-interface.md](engine-interface.md) | The calls, errors, rules issues, strict mode | -| [engine-core.md](engine-core.md) | How loadRuleset, computeSheet, listOptions and applyChanges behave: activation, field computation, stacking, issues, routing | -| [engine-actions.md](engine-actions.md) | How startAction and continueAction run actions: building and rolling rolls, checkpoints, the log and replay | -| [display-hints.md](display-hints.md) | Groups, priorities, formats, `showWhen` | -| [i18n.md](i18n.md) | Translation files and key conventions | -| [upgrades.md](upgrades.md) | Upgrade scripts and spec upgrades | -| [versioning.md](versioning.md) | Spec, engine and pack versions | +| Document | Covers | +| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [packs.md](packs.md) | Pack layout, manifest, IDs, dependencies, overrides, licensing metadata | +| [rules-and-fields.md](rules-and-fields.md) | Fields, families, open enums, tags, settings (rounding, stacking, competing), constraints, result bands, situational choices, link slots and shared sheets, follow-ups, creation starts | +| [content.md](content.md) | Content types, items, per-copy state, templates, `extends`, queries, grants, prerequisites, choices, computed values, auras, `effectsWhen` | +| [effects.md](effects.md) | Effect targets, operations and their order, competition, stacking, rounding, loops | +| [formulas.md](formulas.md) | The formula language: grammar, tree, types, functions, dice, errors | +| [dice-and-actions.md](dice-and-actions.md) | Dice notation and modifiers, roll items, actions, checkpoints, logs, safety limits | +| [characters.md](characters.md) | The character file, a character's own homebrew, creating a character, and exports | +| [engine-interface.md](engine-interface.md) | The calls, errors, rules issues, strict mode | +| [engine-core.md](engine-core.md) | How loadRuleset, computeSheet, listOptions and applyChanges behave: activation, field computation, stacking, issues, routing | +| [engine-actions.md](engine-actions.md) | How startAction and continueAction run actions: building and rolling rolls, checkpoints, the log and replay | +| [display-hints.md](display-hints.md) | Groups, priorities, formats, `showWhen` | +| [i18n.md](i18n.md) | Translation files and key conventions | +| [upgrades.md](upgrades.md) | Upgrade scripts and spec upgrades | +| [versioning.md](versioning.md) | Spec, engine and pack versions | ## Machine-readable parts - [`../schemas/`](../schemas/): JSON Schema (draft 2020-12) for every file format and every engine input and output. Each schema's `$id` is under `https://opencharacterformat.org/schemas/0.3/`, where the website serves the schemas, so each ID is also the schema's URL. Schema files that disagree with this prose are a spec bug. Until it is fixed, the stricter of the two applies. -- [`../data/`](../data/): the **fixed sets** as JSON: effect operations, roll modifiers, dice modifiers, die types, change types, stacking policies, error codes, rules-issue codes, upgrade steps, spec upgrades, value types, rounding modes, display formats, safety limits and the provisional formula context. Each schema enum must equal the matching fixed set; the spec package's tests check this. +- [`../data/`](../data/): the **fixed sets** as JSON: effect operations, roll modifiers, dice modifiers, die types, change types, stacking policies, error codes, rules-issue codes, upgrade steps, spec upgrades, value types, rounding modes, display formats, safety limits and the provisional formula context. Each schema enum must equal the matching fixed set; the spec package's tests check this. Each set's `specVersion` is the spec version that introduced the set; entries added later don't change it. - [`../i18n/en.yaml`](../i18n/en.yaml): standard English text for the spec's error and rules-issue codes. - [`../src/types.ts`](../src/types.ts): TypeScript types generated from the schemas (`pnpm types`), exported by `@opencharacter/spec`. The schemas are the source; the types are a convenience and are a little looser where a schema adds requirements conditionally. - [`../tests/`](../tests/): the shared test suite: plain-data cases for the formula language and every engine call. diff --git a/packages/spec/docs/characters.md b/packages/spec/docs/characters.md index 51f358481..008e13a5a 100644 --- a/packages/spec/docs/characters.md +++ b/packages/spec/docs/characters.md @@ -128,8 +128,8 @@ inventory: An applied entry has an `id` unique within the file too, its `source` (a content item, not a template), `active` (default true), `state`: values for keys declared by the source's content type or item, `aura` for an aura's entry (one of the source's `auras`, whose effects and immunities alone -it runs; [content.md](content.md#auras)), and `appliedBy`: the `id` of the character who applied it, which an action fills in -with its actor's, or the participant its `appliedBy` formula names (optional; a hand-made entry may name none). Formulas read it as +it runs; [content.md](content.md#auras)), and `appliedBy`: the `id` of the character who applied it, which an action or a follow-up +fills in with its character's `id`, or with the character its `applyEffect`'s `appliedBy` formula names (optional; a hand-made entry may name none). Formulas read it as `self.appliedBy` (`''` when absent; `appliedBy` is never a state key name), and changes reach every entry one character applied with `applied: { appliedBy }` ([engine-core.md](engine-core.md#applychanges)), so one creature's mark or aura ends wherever it put it ([dice-and-actions.md](dice-and-actions.md#participants)). An action may apply one without choosing its `id`: applyChanges gives it diff --git a/packages/spec/docs/content.md b/packages/spec/docs/content.md index fde0d17c3..3eed93fb1 100644 --- a/packages/spec/docs/content.md +++ b/packages/spec/docs/content.md @@ -57,7 +57,8 @@ contentTypes: # A formula: item.array[0], sumList(item.array, x) ``` -`of: integer` is for a `list` only (a schema error elsewhere), and takes no `where`. +`of: integer` is for a `list` only (a schema error elsewhere), and takes no `where` (one is a +`formula-error` at load, `unknown-name`). - **Item fields only:** a content type's `fields` and the rules' `templateFields`. A `list` state key, action parameter or template parameter is a validation error. @@ -131,8 +132,8 @@ contentTypes: - Content types can't be overridden, so a homebrew or a house rule can't lift a limit; a table that allows more runs without strict mode and ignores the issue. -Text: `types..name`, and optionally a name for each state key and computed value -(`types..state..name`, `types..computed..name`). A type may also declare +Text: `types..name` (or the key the type's `label` names), and optionally a name for each +state key and computed value (`types..state..name`, `types..computed..name`). A type may also declare **named texts** (`texts: [material, trigger]`): extra text each item may have, looked up as `.texts.`, none required ([i18n.md](i18n.md#key-conventions)). A type with `copies` may word its limit as `types..copies` (optional; `max` and `count` as @@ -213,31 +214,36 @@ magicItem: A content item ([`content-item.schema.json`](../schemas/content-item.schema.json)) is mechanics only. Its text lives in i18n under `.name` and `.description`. -| Property | Meaning | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id`, `type` | Local ID (matching the file path) and content type | -| `override` | Instead of `id`/`type`: patch another pack's item ([packs.md](packs.md#overrides-hiding-and-extends)) | -| `extends` | Permanent variant of another item | -| `tags` | Free-form tags for queries (`martial`, `sword`, `finesse`) | -| `fields` | Values for the type's item fields: literals or formulas. They MUST match the declared types; undeclared names and missing required fields are errors (`invalid-item-field`). | -| `state` | Extra per-copy state keys beyond the type's | -| `effects` | [effects.md](effects.md) | -| `effectsWhen` | A condition every effect of the item needs, replacing its type's ([above](#a-condition-every-effect-needs)) | -| `followUps` | What the rules call for after a change while the character has the item ([rules-and-fields.md](rules-and-fields.md#follow-ups)) | -| `actions` | Inline actions, or `{ use: , params }`; `scope: applied` offers one on the character the item is applied to | -| `choices` | See below | -| `grants` | `{ item, when, available?, scope? }`: other content made active while this item is active (class features at level N); `scope: applied` while it is applied (below); `item` may be a formula or a query, and `forEach` makes it once per element | -| `prerequisites` | `{ id, when }`: conditions for having the item | -| `immuneTo` | `{ id, items \| query, when?, scope? }`: applied content the character can't be affected by while the item is active (below) | -| `reapply` | `{ match?, state }`: what applying the item again does, replacing its type's (below) | -| `appliesTo` | Templates only | -| `removeTags` | Templates only: tags the copy's item doesn't have ([below](#templates)) | -| `params` | Templates only: values its item fields read as `params.`, given by the content keeping it ([below](#from-the-keeper)) | -| `keepsTemplates` | Templates kept on the character's copies while the item is active ([below](#kept-templates)) | -| `keepsFields` | Item fields the templates it keeps leave alone ([below](#kept-templates)) | -| `repeatable` | `true`: exempt from exclusivity groups, so several choices of one can pick it ([below](#exclusive-choices)) | -| `grantOnly` | `true`: held only through grants and picks; `addItem` of it is `not-addable`. Replaces its type's ([above](#grant-only-content)) | -| `deprecated` | `{ since, replacedBy? }` | +| Property | Meaning | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id`, `type` | Local ID (matching the file path) and content type | +| `override` | Instead of `id`/`type`: patch another pack's item ([packs.md](packs.md#overrides-hiding-and-extends)) | +| `extends` | Permanent variant of another item | +| `tags` | Free-form tags for queries (`martial`, `sword`, `finesse`) | +| `fields` | Values for the type's item fields: literals or formulas. They MUST match the declared types; undeclared names and missing required fields are errors (`invalid-item-field`). | +| `state` | Extra per-copy state keys beyond the type's | +| `nameFrom` | A string state key whose value names the copy ([below](#per-copy-fields)) | +| `computed` | Values worked out for each copy or activation ([below](#computed-values)) | +| `effects` | [effects.md](effects.md) | +| `suppress` | IDs of the type's effects this item switches off ([above](#content-type-effects-and-grants)) | +| `effectsWhen` | A condition every effect of the item needs, replacing its type's ([above](#a-condition-every-effect-needs)) | +| `auras` | Areas whose effects and immunities reach whoever the app places inside ([below](#auras)) | +| `followUps` | What the rules call for after a change while the character has the item ([rules-and-fields.md](rules-and-fields.md#follow-ups)) | +| `actions` | Inline actions, or `{ use: , params }`; `scope: applied` offers one on the character the item is applied to | +| `choices` | See below | +| `grants` | `{ item, when?, available?, scope?, state?, choices?, forEach?, waivePrerequisites? }`: other content made active while this item is active (class features at level N); `scope: applied` while it is applied (below); `item` may be a formula or a query, and `forEach` makes it once per element | +| `prerequisites` | `{ id, when }`: conditions for having the item | +| `immuneTo` | `{ id, items \| query, when?, scope? }`: applied content the character can't be affected by while the item is active (below) | +| `reapply` | `{ match?, state }`: what applying the item again does, replacing its type's (below) | +| `appliesTo` | Templates only | +| `removeTags` | Templates only: tags the copy's item doesn't have ([below](#templates)) | +| `params` | Templates only: values its item fields read as `params.`, given by the content keeping it ([below](#from-the-keeper)) | +| `keepsTemplates` | Templates kept on the character's copies while the item is active ([below](#kept-templates)) | +| `keepsFields` | Item fields the templates it keeps leave alone ([below](#kept-templates)) | +| `standardCopies` | Queries for content every holder has a copy of while the item is active ([below](#standard-copies)) | +| `repeatable` | `true`: exempt from exclusivity groups, so several choices of one can pick it ([below](#exclusive-choices)) | +| `grantOnly` | `true`: held only through grants and picks; `addItem` of it is `not-addable`. Replaces its type's ([above](#grant-only-content)) | +| `deprecated` | `{ since, replacedBy? }` | ### Auras @@ -246,8 +252,8 @@ null's field, a banner, a ward. Positions are the app's, so the pack says what t the app says who is in it: ```yaml -# Draw Steel's Null Field: each enemy inside has its potencies lowered, and the extra effect the -# null bought this round on top, read live from the null's sheet. +# Draw Steel's Null Field: each enemy inside has its potencies lowered, and with Synaptic Break +# bought this round, its resistance too, read live from the null's sheet. id: abilities.null.null-field type: ability auras: @@ -256,19 +262,25 @@ auras: size: nullClass.fieldSize # how far, in the game's unit affects: enemies # all (default), allies or enemies fields: # the null's fields the effects read, as a link slot declares them - nullClass.fieldEffect: { type: string, default: none } + nullClass.fieldEffect: + type: enum + values: [none, gravitic-disruption, inertial-anchor, synaptic-break] + default: none effects: - - { target: potency.mod, op: add, value: -1 } - - target: speed + - { target: potency.bonus, op: add, value: -1, stackKey: null-field } + - target: potency.resistBonus.might # and each other characteristic's op: add value: -1 - when: applier.nullClass.fieldEffect == 'inertial-anchor' + stackKey: synaptic-break + when: applier.nullClass.fieldEffect == 'synaptic-break' ``` - **The holder.** computeSheet lists each aura of its active holder content whose `when` holds, in `auras`, with `size` evaluated and `affects` ([engine-interface.md](engine-interface.md#computesheet)). `when` and `size` see the item's `item` and `self`. A `when` or `size` that fails leaves the - aura out, with the formula's error in `errors`. + aura out, with the formula's error in `errors`. The item's `effectsWhen` must hold too + ([above](#a-condition-every-effect-needs)), and content whose grant's `available` fails has + none up ([below](#conditions-on-computed-values)). - **A cast** (`scope: applied`; `holder` is the default). Applied entries of the item project the aura instead: computeSheet lists it once per active, unblocked applied entry of the item (or content its applied grants reach) that the character applied itself (no `appliedBy`, or @@ -729,7 +741,7 @@ features: the copy's value of the key is a non-empty string (not only spaces), it is the copy's name: apps show it in place of the item's name, in the sheet's lists and as a choice's copy option (a faction the GM named, a FitD harm's "Broken leg"). An item's `nameFrom` replaces its type's. -computeSheet gives the names in `copyNames` ([engine-core.md](engine-core.md)). The value is +computeSheet gives the names in `copyNames` ([engine-interface.md](engine-interface.md#computesheet)). The value is saved text the player or GM wrote, like a character's name, not pack text, so it has no translation key. It changes nothing calculated. The validator reports a `nameFrom` that names no string state key. @@ -1072,11 +1084,11 @@ fields: { range: 'prior.range + params.feet' } keepsTemplates: - template: templates.elemental-connection content: { type: spell, where: 'item.id == @spells.absolute-zero' } - when: "countList(character.applied, x.active && x.source == ':spells.elemental-weapon') > 0" + when: 'countList(character.applied, x.active && x.source == @spells.elemental-weapon) > 0' params: element: >- filterList(character.applied, - x.active && x.source == ':spells.elemental-weapon')[0].state.element + x.active && x.source == @spells.elemental-weapon)[0].state.element --- id: templates.elemental-connection type: template @@ -1191,27 +1203,31 @@ never be valid, and `pnpm validate` reports them. A **choice** ([`choice.schema.json`](../schemas/choice.schema.json)) is declared on content, on an inline option, or in the rules: -| Property | Meaning | -| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Unique among its owner's choices | -| `slots` | Integers written out: one choice per slot, keyed `-`, with `slot` bound ([below](#choice-slots)) | -| `from` | Where the options come from: a **query**, `{ items: [refs] }`, `{ values }` (plain values such as skill names; below), or `{ options: [inline options] }` | -| `pick` | Count (formula) or `{ min, max }`. Default 1. | -| `when` | When it applies (`self.level >= 1`). Before that it is neither pending nor active. | -| `required` | Default true. A required choice with too few picks is pending. | -| `recommend` | Formula naming the recommended option; `options` is bound ([below](#eligibility-and-own-picks)) | -| `effects` | Applied once per pick, with the option bound to `pick`. Used for value options ("grant proficiency in `pick.value`"). | -| `activate` | Content choices: `false` keeps the picks inactive (below). Default true. | -| `activeWhen` | When the picks are active (`victories >= 5`); while it fails they are kept but inactive ([below](#picks-active-only-while-a-condition-holds)) | -| `addAs` | Content choices: `copy` holds each pick as a copy of its own, added and removed with the pick ([below](#picks-held-as-copies)) | -| `prompt` | Override for the default key `.choices..prompt` | -| `eligible` | Per option, apart from `from`: a condition with `item` (content) or `value`, and `choice` ([below](#eligibility-and-own-picks)) | +| Property | Meaning | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Unique among its owner's choices | +| `slots` | Integers written out: one choice per slot, keyed `-`, with `slot` bound ([below](#choice-slots)) | +| `from` | Where the options come from: a **query**, `{ items: [refs] }`, `{ values }` (plain values such as skill names; below), or `{ options: [inline options] }` | +| `pick` | Count (formula) or `{ min, max }`. Default 1. | +| `when` | When it applies (`self.level >= 1`). Before that it is neither pending nor active. | +| `required` | Default true. A required choice with too few picks is pending. | +| `recommend` | Formula naming the recommended option; `options` is bound ([below](#eligibility-and-own-picks)) | +| `effects` | Applied once per pick, with the option bound to `pick`. Used for value options ("grant proficiency in `pick.value`"). | +| `activate` | Content choices: `false` keeps the picks inactive (below). Default true. | +| `activeWhen` | When the picks are active (`victories >= 5`); while it fails they are kept but inactive ([below](#picks-active-only-while-a-condition-holds)) | +| `addAs` | Content choices: `copy` holds each pick as a copy of its own, added and removed with the pick ([below](#picks-held-as-copies)) | +| `prompt` | Override for the default key `.choices..prompt` | +| `eligible` | Per option, apart from `from`: a condition with `item` (content) or `value`, and `choice` ([below](#eligibility-and-own-picks)) | +| `unique` | An exclusivity group key: an option picked in one choice of the group is ineligible in the others ([below](#exclusive-choices)) | +| `uniqueScope` | Where `unique` holds: `character` (default) or `owner`, the choices of one owner activation ([below](#exclusive-choices)) | +| `requiresOption` | `true`: while the choice is pending with no eligible option, its owner has `no-eligible-option` ([above](#grants-and-prerequisites)) | +| `waivePrerequisites` | `true`: picks report no `prerequisite-not-met` ([above](#grants-and-prerequisites)) | Picked **content** becomes active: its effects, grants and its own choices apply. Content that declares state (its type's or its own) keeps it on the pick: it reads `self.` from the pick's `state` (else the declared default) over the owner's copy, and `setState` and `adjustState` change it by the pick's `path` and `option`, or `pick: self` in its -own action ([engine-core.md](engine-core.md#applychanges)). An **inline option** (`id`, `tags`, `prerequisites`, `effects`, `grants`, `choices`, `immuneTo`) behaves the same way. Text: `.choices..options..name`, and an optional `.choices..options..description` (Markdown), so a random table of named paragraphs (a d6 of inciting incidents) is a choice of inline options. Its `immuneTo` holds while it is picked and active, as an item's does ([above](#when-content-is-active)); the `immune` issue's `id` is the option's source ID plus the immunity's (`//