From 85fe6b6e56207d2a89ecd8c9b4156d72f8f2029f Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:59:11 -0700 Subject: [PATCH 01/13] Website: name the LicenseRef- licenses in the home packs table Signed-off-by: Mitchel Pigsley --- apps/website/scripts/sync-docs.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/website/scripts/sync-docs.ts b/apps/website/scripts/sync-docs.ts index aed17b1a5..72c145be0 100644 --- a/apps/website/scripts/sync-docs.ts +++ b/apps/website/scripts/sync-docs.ts @@ -110,7 +110,8 @@ interface Ecosystem { const LICENSES: Record = { 'mechanics-only': 'Mechanics only', ORC: 'ORC License', - 'OGL-1.0a': 'OGL 1.0a', + 'LicenseRef-draw-steel-creator-license': 'DRAW STEEL Creator License', + 'LicenseRef-sine-nomine-free-edition': 'Free edition, with permission', }; /** MDX text: escapes the characters MDX reads as JSX or expressions. */ @@ -171,6 +172,9 @@ function ecosystem(root: string): string { "_Mechanics only_: the game isn't openly licensed, so the pack holds its math and names, no", "rules text, and isn't affiliated with or endorsed by its publisher.", '', + '_Free edition, with permission_ and _DRAW STEEL Creator License_: the rights holder allows', + "the pack's rules text; see the pack's credit and notices.", + '', '', '', '
', From 1e3fb764c525edec334db4905b0ea1b5a7b464b6 Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:59:11 -0700 Subject: [PATCH 02/13] Guides: other characters: a runnable aura sample, appliers on every call, keep the observer's file Signed-off-by: Mitchel Pigsley --- apps/website/guides/other-characters.md | 98 ++++++++++++++++++------- 1 file changed, 71 insertions(+), 27 deletions(-) diff --git a/apps/website/guides/other-characters.md b/apps/website/guides/other-characters.md index d9951fd5f..96fd26800 100644 --- a/apps/website/guides/other-characters.md +++ b/apps/website/guides/other-characters.md @@ -91,7 +91,9 @@ while ('status' in cast && cast.status === 'paused' && cast.needs.kind === 'hand - **The log records each answer** (`kind: 'handoff'`), so a paused action replays exactly. - **Content can add handoffs to an action**: a feature's `action` effect can hand off its own save on a hit. Its `needs.step` is the hit step's path, then the content and the effect's ID - (`deal/#stun[0]`); answer it like any other. + (`deal/#stun[0]`); answer it like any other. Without a participant the added run + happens on the actor: its rolls and choices pause like any nested action's, at that path, so a + policy rule on `step` can target them. - **A handoff may name other characters for the run**: `needs.run.participants` lists IDs by role, the running character's own `id` included. Pass their views as the run's `participants`, as you would to any action. A Fate attack names the attacker this way, so the @@ -219,37 +221,65 @@ A null's field, a paladin's aura, a banner: an aura puts effects on whoever is i creatures stand is your app's business, so the pack lists the auras and you place their entries. The holder's `computeSheet` lists what it projects now in `auras`; apply an entry of each to every creature inside that `affects` names, and remove it when they leave or the aura drops off the -list: +list. The creatures inside need the aura's pack in their own ruleset, so an ally statted with +the same packs: ```ts -const nullSheet = computeSheet(rulesetFor(nullHero), nullHero); -// nullSheet.auras: [{ source: 'draw-steel-heroes:abilities.null.null-field', aura: 'field', -// entry: 'null-field', size: 1, affects: 'enemies' }] -for (const a of nullSheet.auras) - for (const enemy of enemiesWithin(nullHero, a.size)) - applyChanges(rulesetFor(enemy), enemy, [ - { type: 'applyEffect', source: a.source, aura: a.aura, appliedBy: nullHero.id }, +const paladinSheet = computeSheet(rulesetFor(paladin), paladin); +// paladinSheet.auras: [{ source: '5e-srd-5.1:features.paladin.aura-of-protection', +// aura: 'protection', entry: 'paladin', size: 10, affects: 'allies' }] +// `entry` is the copy that holds it (here, the paladin class). +for (const a of paladinSheet.auras) + for (const ally of alliesWithin(paladin, a.size)) { + const placed = applyChanges(rulesetFor(ally), ally, [ + { + type: 'applyEffect', + id: `${a.aura}-${paladin.id}`, + source: a.source, + aura: a.aura, + appliedBy: paladin.id, + }, ]); + if (placed.ok) save(ally.id, placed.character); // your app's storage + } +// When the ally leaves: { type: 'removeEffect', id: `protection-${paladin.id}` }. ``` -The aura's effects read the holder's live state (the extra effect the null bought this round), -so pass the holder's view as `appliers` whenever you compute or act for a creature inside: +Give each entry an ID of your own, as above, so you can remove it later. Name the entry on the +creature's sheet with the key `.auras..name` in the source's pack +([Showing text](showing-text.md#effects-and-auras)). + +The aura's effects read the holder's live values (the paladin's Charisma modifier now), so pass +the holder's view as `appliers` whenever you compute or act for a creature inside: ```ts -const appliers = [view(nullHero, nullSheet)]; -const goblinSheet = computeSheet(rulesetFor(goblin), goblin, [], {}, appliers); -startAction(rulesetFor(goblin), goblin, { action: { id: 'monster-attack' }, appliers }); +const appliers = [view(paladin, paladinSheet)]; +const allySheet = computeSheet(rulesetFor(tamsin), tamsin, [], {}, appliers); +// allySheet.values['5e-srd-5.1:saves.wis'].value: 4 (2 without the view) +startAction(rulesetFor(tamsin), tamsin, { + action: { id: '5e-srd-5.1:saving-throw' }, + params: { ability: 'wis', dc: 15 }, + appliers, +}); ``` -Without the view, the effects read the defaults the aura declares. Nothing in the goblin's file -changes when the null's does: recompute it with the new view. +Pass the same `appliers` to every call for that creature, since an effect that reads `applier` +can run in any of them: + +- `applyChanges(ruleset, tamsin, changes, false, {}, undefined, appliers)`: the 7th argument, + after `strict`, `links` and `dice`. +- `listOptions(ruleset, tamsin, path, {}, appliers)` and + `describeAction(ruleset, tamsin, action, {}, appliers)`. + +Without the view, the effects read the defaults the aura declares (a Charisma modifier of 1 +here). Nothing in the ally's file changes when the paladin's does: recompute it with the new view. An aura can also make whoever is inside immune to something (Aura of Courage: no one inside is frightened). The entry you placed blocks the matching condition on that creature, reported as the rules issue `immune`; you do nothing more. -Some auras come from a cast rather than from what the character holds: Bless or Spirit Guardians -around the caster. The cast puts the spell on the caster as an applied entry, and the caster's +Some auras come from a cast rather than from what the character holds: orc-2e's bless or 5E's +Spirit Guardians around the caster. The cast puts the spell on the caster as an applied entry, and the caster's `auras` lists the aura once per cast, with `entry` that applied entry's ID. Place and remove its entries as for any aura. Two casts are two listings, told apart by `entry`, and a cast's aura leaves the list when its entry ends (removed or turned off), wherever the spell was cast from. @@ -285,7 +315,8 @@ function turnChange(everyone, ended, started) { } ``` -It needs nothing else from you and never pauses: every parameter has a default, no role is +Pass each creature's `links` and `appliers` as for any call; an aura's effects can read the turn +too. It needs nothing else from you and never pauses: every parameter has a default, no role is required, no step asks a choice, and its rolls roll with your dice function. The pack uses it to reset what lasts a turn and to end what the creature whose turn starts applied until then (its entries record it in `appliedBy`). @@ -437,12 +468,20 @@ attacker's ID in `character`: ```ts const hit = applyChanges(rulesetFor(talent), talent, forTalent); -for (const offer of hit.followUps) { - if (offer.to !== 'character') continue; - // offer.character: 'goblin-1'; offer.changes: an applyEffect, appliedBy the talent. - const other = byId[offer.character]; - applyChanges(rulesetFor(other), other, offer.changes); - applyChanges(rulesetFor(talent), talent, [{ type: 'clearFollowUp', id: offer.id }]); +if (hit.ok) { + talent = hit.character; + for (const offer of hit.followUps) { + if (offer.to !== 'character') continue; + // offer.character: 'goblin-1'; offer.changes: an applyEffect, appliedBy the talent. + const other = byId[offer.character]; + const slowed = applyChanges(rulesetFor(other), other, offer.changes); + if (slowed.ok) save(other.id, slowed.character); + const cleared = applyChanges(rulesetFor(talent), talent, [ + { type: 'clearFollowUp', id: offer.id }, + ]); + if (cleared.ok) talent = cleared.character; + } + save(talent.id, talent); } ``` @@ -470,7 +509,9 @@ const observes = new Map(); // character ID -> the fields its follow-ups watch o function apply(c, changes) { const result = applyChanges(rulesetFor(c), c, changes); - if (result.ok) observes.set(c.id, computeSheet(rulesetFor(c), result.character).observes ?? []); + if (!result.ok) return result; + save(c.id, result.character); + observes.set(c.id, computeSheet(rulesetFor(c), result.character).observes ?? []); return result; } @@ -481,13 +522,16 @@ function applyAndShare(target, changes) { for (const other of everyone) { if (other.id === target.id) continue; if (!(observes.get(other.id) ?? []).some((f) => fields.has(f))) continue; - const seen = applyChanges(rulesetFor(other), other, [result.observe]); + const seen = apply(other, [result.observe]); // its offers live in its file now // seen.followUps: the tactician's `mark-damaged` offer, say. Show it; don't run it here. } return result; } ``` +- **Save the observer's file.** The offers an `observe` fires are stored in the observer's + file, like any ephemeral offer, so keep the `character` its `applyChanges` returns, as `apply` + does above. - **Log it like any change.** The `observe` change is in the observer's change list, so replaying the list stores the same follow-ups. Routed events grow every observer's change log, so send them only where `observes` says they matter. From c75510bb21b547add825a675264758de505c75a7 Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:59:11 -0700 Subject: [PATCH 03/13] Guides: showing text: added parameters, situational choices, aura names, step text, parameter errors Signed-off-by: Mitchel Pigsley --- apps/website/guides/showing-text.md | 46 ++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/website/guides/showing-text.md b/apps/website/guides/showing-text.md index 74f319a9c..45cb3b624 100644 --- a/apps/website/guides/showing-text.md +++ b/apps/website/guides/showing-text.md @@ -141,9 +141,16 @@ A form labels each input with the pack's text, never a humanized ID: text.typeField('mork-borg-core:weapon', 'attackDrModifier'); // 'Attack DR modifier' text.typeFieldDescription('5e-srd-5.1:armor', 'maxDex'); // 'The most Dexterity modifier AC adds.' text.templateField('5e-srd-5.1:acBonus'); // 'AC bonus', for a custom template's form +text.templateFieldDescription('5e-srd-5.1:acBonus'); // its hint, or undefined text.param('mork-borg-core:test', 'dr'); // 'Difficulty rating (DR)' text.paramDescription('mork-borg-core:test', 'dr'); // 'The target to beat. 12 for most rolls; the GM raises or lowers it.' + +// A parameter content adds (describeAction's `addedBy`): Hurl Element's damage type. +const hurl = 'draw-steel-heroes:abilities.elementalist.hurl-element'; +text.addedParam(hurl, 'element'); // 'Element' +text.addedParamValue(hurl, 'element', 'fire'); // 'Fire' +text.addedParamDescription(hurl, 'element'); // its hint, or undefined ``` - **Every item field has a name** (`types..fields..name`); a hint @@ -152,6 +159,37 @@ text.paramDescription('mork-borg-core:test', 'dr'); - **A parameter's hint** is `.params..description`, optional. `action` is `needs.action`'s form, so an inline action is `#`. A parameter with an explicit `label` key is named by `text.lookup` with that key. +- **A parameter content adds** is named from the content's text + (`.params..name`, `.description`, `.values..name`), so pass describeAction's + `addedBy`, not the action. + +## Situational choices + +The toggles computeSheet lists in `situational` (advantage, a Bardic Inspiration die) and the +reactions a change offers ([Changing characters](changing-characters.md#reactions-to-one-change)) +are named by their ID: + +```ts +text.situational('5e-srd-5.1:advantage'); // 'Advantage' +text.situational('5e-srd-5.1:bardicInspiration'); // 'Bardic Inspiration die' +text.situationalValue('5e-srd-5.1:bardicInspiration', 'd8'); // 'd8' +``` + +The keys are `situational..name` and `situational..values..name` in the choice's +pack. A choice with its own `label` key is named by `text.lookup`. + +## Effects and auras + +An applied entry is named by its content: `text.content(entry.source)` ("Bless"). An aura's +entry may have a name of its own, `.auras..name` in the source's pack, which no +official pack writes yet. The helper has no method for it: look it up with `text.message`, and +fall back to the source's name: + +```ts +const [pack, local] = entry.source.split(/:(.*)/); +text.message(pack, `${local}.auras.${entry.aura}.name`) ?? text.content(entry.source); +// 'Aura of Protection' +``` ## Headlines @@ -246,6 +284,9 @@ if (result.status === 'paused') { | `dice` | the roll step's name, else the action's | `dice`, `roll`, and `targets` for a banded roll | | `handoff` | the name of the action the participant runs | | +For a pause you word yourself, `text.stepPrompt(needs)` gives the step's prompt and +`text.stepName(needs)` its name, each falling back to the action's name. + A FitD action roll with a push, for a scoundrel with Battleborn: ```ts @@ -320,7 +361,10 @@ text.error(error); // the spec's text, data filled with its choice's prompt (`issues.too-many-picks.choice`), and a `safety-limit-exceeded` has a sentence per limit (`errors.safety-limit-exceeded.maxOverflowChain`). - A parameter's error names the parameter by its label, and its values by theirs, from the - action's text (the error's `action`). + action's text (the error's `action`), or from the content's for an added one (`addedBy`): + `invalid-param` reads `"Cost" is worked out by the rules, so it cannot be given.` An + `out-of-range` for a bound content added has `boundBy`, which the text doesn't name; add + `text.content(error.data.boundBy)` if you want to say why. - Text whose data is missing an argument is never shown raw. A code with no text, or whose text misses an argument, reads as the error's `data.message` when it has one, else `errors.other` ("Something went wrong."). Neither shows the code: log the error itself for developers. From 8da06b57fc04656771f4a3f5b21598c00469c44d Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:59:11 -0700 Subject: [PATCH 04/13] Guides: changing characters: every change type, naming reactions, held-copy costs don't clamp Signed-off-by: Mitchel Pigsley --- apps/website/guides/changing-characters.md | 58 +++++++++++++--------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/apps/website/guides/changing-characters.md b/apps/website/guides/changing-characters.md index d81f9f49e..e0c39ae37 100644 --- a/apps/website/guides/changing-characters.md +++ b/apps/website/guides/changing-characters.md @@ -26,27 +26,36 @@ There's a small, fixed set of generic change types ([change.schema.json](../../../packages/spec/schemas/change.schema.json)). What's inside a change (does this field exist, is 25 in range) is checked against the ruleset. -| Type | Does | Example | -| ---------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `set` | Sets an input field | `{ type: 'set', field: '5e-srd-5.1:abilities.str.base', value: 16 }` | -| `adjust` | Adds to a numeric input field, through resistances | `{ type: 'adjust', field: '5e-srd-5.1:hp.current', by: -10, tags: ['damage.fire'] }` | -| `addItem` | Adds a copy of content to `inventory` or `features` | `{ type: 'addItem', entry: { id: 'inv-6', item: '5e-srd-5.1:weapons.dagger' } }` | -| `removeItem` | Removes a copy, with its picks (not a standard copy) | `{ type: 'removeItem', entry: 'inv-6' }` | -| `equip` | Equips or unequips a copy | `{ type: 'equip', entry: 'inv-4', equipped: true }` | -| `setState` | Sets a per-copy state value | `{ type: 'setState', entry: 'inv-2', key: 'ignited', value: true }` | -| `adjustState` | Adds to a numeric per-copy state value | `{ type: 'adjustState', entry: 'fighter', key: 'level', by: 1 }` | -| `setField` | Sets a copy's own value of a `perCopy` item field (`null` removes it) | `{ type: 'setField', entry: 'stunt-1', key: 'limit', value: 'scene' }` | -| `setQuantity` | Sets a copy's quantity (not below 0; a copy at 0 stays) | `{ type: 'setQuantity', entry: 'inv-3', value: 20 }` | -| `adjustQuantity` | Adds to a copy's quantity (spending an arrow) | `{ type: 'adjustQuantity', entry: 'inv-3', by: -1 }` | -| `applyTemplate` | Applies a template to a copy | `{ type: 'applyTemplate', entry: 'inv-6', template: '5e-srd-5.1:magic-items.weapon-plus-one' }` | -| `removeTemplate` | Removes a template from a copy | `{ type: 'removeTemplate', entry: 'inv-6', template: '…' }` | -| `applyEffect` | Starts a temporary effect from content not owned | `{ type: 'applyEffect', id: 'bless-1', source: '5e-srd-5.1:spells.bless' }` | -| `toggleEffect` | Turns a temporary effect on or off | `{ type: 'toggleEffect', id: 'bless-1', active: false }` | -| `removeEffect` | Removes a temporary effect | `{ type: 'removeEffect', id: 'bless-1' }` | -| `choose` | Picks an option for a choice | `{ type: 'choose', path: { entry: 'fighter', choice: ['fighting-style'] }, option: '…' }` | -| `unchoose` | Un-picks an option, and everything nested under it | `{ type: 'unchoose', path: { entry: 'fighter', choice: ['fighting-style'] }, option: '…' }` | -| `setExtension` | Replaces your app's own data | `{ type: 'setExtension', namespace: 'com.example.vtt', value: { … } }` | -| `setHomebrew` | Replaces (or, with `null`, removes) the character's own homebrew | `{ type: 'setHomebrew', homebrew: { content: [ … ] } }` | +| Type | Does | Example | +| ----------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `set` | Sets an input field | `{ type: 'set', field: '5e-srd-5.1:abilities.str.base', value: 16 }` | +| `adjust` | Adds to a numeric input field, through resistances | `{ type: 'adjust', field: '5e-srd-5.1:hp.current', by: -10, tags: ['damage.fire'] }` | +| `addItem` | Adds a copy of content to `inventory` or `features` | `{ type: 'addItem', entry: { id: 'inv-6', item: '5e-srd-5.1:weapons.dagger' } }` | +| `removeItem` | Removes a copy, with its picks (not a standard copy) | `{ type: 'removeItem', entry: 'inv-6' }` | +| `equip` | Equips or unequips a copy | `{ type: 'equip', entry: 'inv-4', equipped: true }` | +| `setState` | Sets a per-copy state value | `{ type: 'setState', entry: 'inv-2', key: 'ignited', value: true }` | +| `adjustState` | Adds to a numeric per-copy state value | `{ type: 'adjustState', entry: 'fighter', key: 'level', by: 1 }` | +| `setField` | Sets a copy's own value of a `perCopy` item field (`null` removes it) | `{ type: 'setField', entry: 'stunt-1', key: 'limit', value: 'scene' }` | +| `setQuantity` | Sets a copy's quantity (not below 0; a copy at 0 stays) | `{ type: 'setQuantity', entry: 'inv-3', value: 20 }` | +| `adjustQuantity` | Adds to a copy's quantity (spending an arrow) | `{ type: 'adjustQuantity', entry: 'inv-3', by: -1 }` | +| `applyTemplate` | Applies a template to a copy | `{ type: 'applyTemplate', entry: 'inv-6', template: '5e-srd-5.1:magic-items.weapon-plus-one' }` | +| `removeTemplate` | Removes a template from a copy | `{ type: 'removeTemplate', entry: 'inv-6', template: '…' }` | +| `applyEffect` | Starts a temporary effect from content not owned (`appliedBy`: who applied it; `aura`: an aura's entry) | `{ type: 'applyEffect', id: 'bless-1', source: '5e-srd-5.1:spells.bless', appliedBy: 'cleric' }` | +| `toggleEffect` | Turns a temporary effect on or off | `{ type: 'toggleEffect', id: 'bless-1', active: false }` | +| `removeEffect` | Removes a temporary effect | `{ type: 'removeEffect', id: 'bless-1' }` | +| `choose` | Picks an option for a choice | `{ type: 'choose', path: { entry: 'fighter', choice: ['fighting-style'] }, option: '…' }` | +| `unchoose` | Un-picks an option, and everything nested under it | `{ type: 'unchoose', path: { entry: 'fighter', choice: ['fighting-style'] }, option: '…' }` | +| `setExtension` | Replaces your app's own data | `{ type: 'setExtension', namespace: 'com.example.vtt', value: { … } }` | +| `setHomebrew` | Replaces (or, with `null`, removes) the character's own homebrew | `{ type: 'setHomebrew', homebrew: { content: [ … ] } }` | +| `finishCreation` | Ends creation (clears `creating`) | `{ type: 'finishCreation' }` | +| `offerFollowUp` | Stores an offer of a follow-up the character has (actions propose it) | `{ type: 'offerFollowUp', followUp: 'draw-steel-heroes:features.conduit.piety-in-combat#domain-effect' }` | +| `clearFollowUp` | Clears an offer acted on | `{ type: 'clearFollowUp', id: 'concentration-1' }` | +| `dismissFollowUp` | Deletes an offer unrun | `{ type: 'dismissFollowUp', id: 'concentration-1' }` | +| `observe` | Another character's batch, for this one's follow-ups to react to | the `observe` another `applyChanges` returned | + +`finishCreation` is covered in [Building characters](building-characters.md#three-ways-to-create-a-character), +the follow-up changes in [What the rules call for next](#what-the-rules-call-for-next), and +`observe` in [Reacting to other characters](other-characters.md#reacting-to-other-characters). Every field and content ID is **qualified** with its pack. Entries (copies) are named by the instance `id` you gave them. @@ -179,7 +188,8 @@ Some choices are made by the character being hit, for that hit only: 5E's Uncann one attack's damage. The pack declares them as situational choices with `incoming`, and computeSheet lists the ones offered in `situational`. Before applying a change, show the receiving player the offered choices whose `incoming` matches it (its field and tags), and put -the ones they pick on the change: +the ones they pick on the change. Name each with `text.situational(id)`, and an enum choice's +value with `text.situationalValue(id, value)` ([Showing text](showing-text.md#situational-choices)): ```ts applyChanges(ruleset, rogue, [ @@ -207,7 +217,9 @@ Two kinds of choice ask a little more of your app: and a follow-up it offers is in `followUps`. A cost may change a held copy's state too (Shield Block's shield takes the damage its hardness didn't stop): its `{ copies }` entry names the choice as `source`. Don't apply the cost yourself. The choice's - `available` says whether it can be paid; a cost past the field's minimum clamps. + `available` says whether it can be paid; a cost past the field's minimum clamps. A held copy's + state doesn't clamp: past its bound the whole batch fails (`out-of-range`), so packs clamp it + themselves. - **A batch-scoped choice** (`scope: batch`: 5E 5.2's Deflect Attacks, which reduces the attack's total damage once) goes on the attack's first change only, and reaches the batch's later changes its `incoming` matches. Apply one attack's changes as one batch, and log the batch as From 53d4fcfceea42f81cb376db1dacc9aae0cd5752f Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:59:11 -0700 Subject: [PATCH 05/13] Guides: storing characters: appliedBy, aura and pick entry; caches depend on links and appliers Signed-off-by: Mitchel Pigsley --- apps/website/guides/storing-characters.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/website/guides/storing-characters.md b/apps/website/guides/storing-characters.md index 33ff4f1ec..e7ad9e2cc 100644 --- a/apps/website/guides/storing-characters.md +++ b/apps/website/guides/storing-characters.md @@ -26,7 +26,7 @@ inventory: # carried content templates: [5e-srd-5.1:magic-items.weapon-plus-one] equipped: true applied: # temporary effects from content the character doesn't own - - { id: bless-1, source: 5e-srd-5.1:spells.bless, active: true } + - { id: bless-1, source: 5e-srd-5.1:spells.bless, active: true, appliedBy: example-cleric } choices: {} # picks for rules-level choices homebrew: {} # the player's own content, if any (below) extensions: # your app's own data @@ -43,6 +43,10 @@ attack bonuses and maximum HP are **never** in the file. They come from `compute time, so a saved character can't disagree with the rules, and a pack fix reaches every character without migrating anything. +An applied entry may name who applied it (`appliedBy`), the aura it belongs to (`aura`), and +state of its own (`state`). A pick that adds a copy (`addAs: copy`) saves that copy's instance ID +as `entry`. Round-trip them all. + Content an item grants can keep state of its own: an artifact's gift whose Effort stays committed. It is saved on the copy (or the pick) that grants it, under `grants`, by the granted content's qualified ID, and left out while at its defaults: @@ -266,7 +270,9 @@ The engine has no partial or incremental compute, so cache yourself: - Compute the sheet when a character changes (after `applyChanges`) and keep the values you need next to the file, keyed by character `id`. -- Throw the cache away when the character, its pinned packs, or the engine version changes. +- Throw the cache away when the character, its pinned packs, the engine version, or a linked or + aura-holding character's values change: a sheet also depends on the `links` and `appliers` + views you pass in. - Never write cached values into the character file. ## Monsters and NPCs From 610a8a56f4e7fb9f82476c94eaaea69516360f34 Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:59:11 -0700 Subject: [PATCH 06/13] Guides: showing a sheet: auras, observes, effectsWhen, aura immunity, re-keying rules-level option IDs Signed-off-by: Mitchel Pigsley --- apps/website/guides/showing-a-sheet.md | 53 ++++++++++++++++---------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/website/guides/showing-a-sheet.md b/apps/website/guides/showing-a-sheet.md index c19f4d0e6..d8ea87f63 100644 --- a/apps/website/guides/showing-a-sheet.md +++ b/apps/website/guides/showing-a-sheet.md @@ -11,26 +11,28 @@ const sheet = computeSheet(ruleset, character); `computeSheet` **never fails**. Problems in the character file go in `sheet.errors`, and the sheet is computed without the broken part. -| Output | What it is | Typical UI | -| ------------------ | -------------------------------------------------------------------------------- | ---------------------------------------------- | -| `values` | Every field's value, with its base, a breakdown and whether it's shown | The sheet itself | -| `groups` | Each display group: whether it's shown, and its members, sorted | Sections of the sheet | -| `effects` | Every active effect, with its source; stacked-out ones `suppressed: true` | "Active effects" panel, tooltips | -| `actions` | Actions available now, with the entry each belongs to | Roll buttons ([Rolling dice](rolling-dice.md)) | -| `situational` | Situational choices offered now (advantage, cover) | Toggles next to roll buttons | -| `pending` | Choices that apply and still need picks | "Choose your fighting style" prompts | -| `offers` | What the rules offer now: derived (level-up) and stored ephemeral offers | "Level up", "Concentration check (12 damage)" | -| `choices` | Every choice that applies, answered or not, in order | A creation wizard's steps | -| `competing` | Fields where several effects compete to set the value | A picker on the field | -| `issues` | Rules issues: valid, but the rules object | Warnings | -| `copies` | Each copy's computed values (an item's quality), by instance ID | Next to the item in the inventory | -| `activationValues` | Picked or granted content's computed values (an ability's damage), by activation | On the ability's card | -| `copyNames` | Copies the player named (`nameFrom`), by instance ID | In place of the item's name | -| `templated` | Every item with templates, as merged (a Longsword +1, a longer cantrip) | Item cards | -| `standardCopies` | The copies every character holds, and whether the file saves each | The inventory, unsaved items greyed out | -| `activations` | Every piece of active content and how it was reached | Content groups | -| `copyLimits` | Per content type, how many copies the character may hold and holds | Replace instead of Add | -| `errors` | Problems with the file itself (unknown items, wrong types) | An import warning | +| Output | What it is | Typical UI | +| ------------------ | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `values` | Every field's value, with its base, a breakdown and whether it's shown | The sheet itself | +| `groups` | Each display group: whether it's shown, and its members, sorted | Sections of the sheet | +| `effects` | Every active effect, with its source; stacked-out ones `suppressed: true` | "Active effects" panel, tooltips | +| `actions` | Actions available now, with the entry each belongs to | Roll buttons ([Rolling dice](rolling-dice.md)) | +| `situational` | Situational choices offered now (advantage, cover) | Toggles next to roll buttons | +| `pending` | Choices that apply and still need picks | "Choose your fighting style" prompts | +| `offers` | What the rules offer now: derived (level-up) and stored ephemeral offers | "Level up", "Concentration check (12 damage)" | +| `choices` | Every choice that applies, answered or not, in order | A creation wizard's steps | +| `competing` | Fields where several effects compete to set the value | A picker on the field | +| `issues` | Rules issues: valid, but the rules object | Warnings | +| `copies` | Each copy's computed values (an item's quality), by instance ID | Next to the item in the inventory | +| `activationValues` | Picked or granted content's computed values (an ability's damage), by activation | On the ability's card | +| `copyNames` | Copies the player named (`nameFrom`), by instance ID | In place of the item's name | +| `templated` | Every item with templates, as merged (a Longsword +1, a longer cantrip) | Item cards | +| `standardCopies` | The copies every character holds, and whether the file saves each | The inventory, unsaved items greyed out | +| `activations` | Every piece of active content and how it was reached | Content groups | +| `copyLimits` | Per content type, how many copies the character may hold and holds | Replace instead of Add | +| `auras` | Auras the character projects now, with whom they affect and how far | Placing entries on creatures inside ([Other characters](other-characters.md#auras)) | +| `observes` | Fields of other characters this one's follow-ups react to (absent when none) | Routing `observe` ([Other characters](other-characters.md#reacting-to-other-characters)) | +| `errors` | Problems with the file itself (unknown items, wrong types) | An import warning | The full shape is in [compute-sheet.schema.json](../../../packages/spec/schemas/engine/compute-sheet.schema.json). @@ -235,6 +237,13 @@ engine never tracks time: when Bless ends, remove or toggle it A character can be **immune** to applied content: a monk with Purity of Body can't be poisoned. The entry stays in the list but has no effect, and `issues` has an `immune` issue whose `data` names the entry (`{ entry, item }`), so you can show it struck through or offer to remove it. +An aura the creature stands in can do the same (Aura of Courage: no one inside is frightened); +the issue's `id` then names the aura (`5e-srd-5.1:features.paladin.aura-of-courage#courage/frightened`). + +A content type or an item can make all its effects wait on its state (`effectsWhen`): a magic +item's effects wait for attunement, an orc-2e item's for investment. Until the condition holds, +the item is listed but its effects aren't in `effects` or `values`. Its `scope: applied` effects +(what it does when cast on someone else) don't wait on it. ## Competing options @@ -278,6 +287,10 @@ These picks aren't part of the character file. Store them yourself (in `extensio should travel with the character), and pass them on every `computeSheet` call. A pick that no longer names an option falls back to the recommendation. +Option IDs are stable within a spec version. Spec 0.4 qualified those of a rules-level choice's +effects with their pack (`orc-2e:rules/qi-tradition/divine#0`, once `rules/qi-tradition/divine#0`), +so re-key picks you saved under `rules/…`: they would otherwise fall back to the recommendation. + ## Pending choices `sheet.pending` lists choices that apply now and still need picks: a new fighter's fighting From 1119dd9c051eff4a77fae261c15925ec13fa73da Mon Sep 17 00:00:00 2001 From: Mitchel Pigsley Date: Mon, 2 Nov 2026 10:59:11 -0700 Subject: [PATCH 07/13] Guides: text and translation: keys for added parameters, computed values, auras, inline options, rules-level choice issues Signed-off-by: Mitchel Pigsley --- apps/website/guides/text-and-translation.md | 73 ++++++++++++--------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/website/guides/text-and-translation.md b/apps/website/guides/text-and-translation.md index 5ecf4a3ee..c31740d9c 100644 --- a/apps/website/guides/text-and-translation.md +++ b/apps/website/guides/text-and-translation.md @@ -85,34 +85,39 @@ function actionName(qid: string) { The key conventions ([Translation](../../../packages/spec/docs/i18n.md#key-conventions)): -| What | The engine gives you | Key, in the ID's pack | -| --------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------- | -| The pack | `pack.id` | `pack.name` | -| A content item | `5e-srd-5.1:armor.shield` | `armor.shield.name`, `armor.shield.description` | -| A field | `5e-srd-5.1:combat.ac` | `fields.combat.ac.name`, `fields.combat.ac.short` | -| A content type | `5e-srd-5.1:weapon` | `types.weapon.name` | -| A display group | group `combat` (with its `pack`) | `groups.combat.label` | -| A rules action | `5e-srd-5.1:weapon-attack` | `actions.weapon-attack.name` | -| An action on an item | `5e-srd-5.1:features.fighter.second-wind#second-wind` | `features.fighter.second-wind.actions.second-wind.name` | -| An action on a content type | `fate-core:stunts.psychologist#use` | `stunts.psychologist.actions.use.name`, else `types.stunt.use.name` | -| An action's choice option | option `hit` of `weapon-attack` | `actions.weapon-attack.options.hit.name` | -| An action's choice question | `needs` `{ action, step: 'hit/confirm' }` | `actions.weapon-attack.steps.confirm.prompt` | -| An action's roll (optional) | `needs` or log `{ action, step: 'damage' }` | `actions.weapon-attack.steps.damage.name` | -| An action's parameter | parameter `targetAc` of `weapon-attack` | `actions.weapon-attack.params.targetAc.name` | -| An action's result | `outcome.result: hit` of `weapon-attack` | `actions.weapon-attack.results.hit.name` | -| Longer text (optional) | an action, a field | `actions.weapon-attack.description`, `fields.hp.current.description` | -| A situational choice | `5e-srd-5.1:advantage` | `situational.advantage.name` | -| A result band | log `band: success`, `bandSet: …:action` | `bands.action.success.name` | -| A named text (optional) | a spell's type declares `texts: [material, trigger]` | `spells.shield.texts.trigger` | -| A copy's value (optional) | a cohort's computed `quality`, its state `harm` | `types.cohort.computed.quality.name`, `types.cohort.state.harm.name` | -| A tag (optional) | tag `finesse` on a weapon | `tags.finesse.name`, `tags.finesse.description` | -| A card header (optional) | a spell, `5e-srd-5.1:spells.fire-bolt` | `spells.fire-bolt.block`, else `types.spell.block` | -| An enum value (optional) | a spell's `school: evocation` | `types.spell.fields.school.values.evocation.name` | -| The headline (optional) | the character, "Level 3 Hill dwarf Wizard" | `headline`; a member's piece `types.class.headline` | -| A choice on content | path `{ entry, choice: ['fighting-style'] }` on a fighter | `classes.fighter.choices.fighting-style.prompt` | -| A rules-level choice | path `{ choice: ['playbook'] }` | `rules.choices.playbook.prompt` | -| A prerequisite (issue `id`) | `5e-srd-5.1:classes.fighter/multiclass` | `classes.fighter.prerequisites.multiclass` | -| A constraint (issue `id`) | `5e-srd-5.1:attunement-limit` | `constraints.attunement-limit` | +| What | The engine gives you | Key, in the ID's pack | +| ----------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| The pack | `pack.id` | `pack.name` | +| A content item | `5e-srd-5.1:armor.shield` | `armor.shield.name`, `armor.shield.description` | +| A field | `5e-srd-5.1:combat.ac` | `fields.combat.ac.name`, `fields.combat.ac.short` | +| A content type | `5e-srd-5.1:weapon` | `types.weapon.name` | +| A display group | group `combat` (with its `pack`) | `groups.combat.label` | +| A rules action | `5e-srd-5.1:weapon-attack` | `actions.weapon-attack.name` | +| An action on an item | `5e-srd-5.1:features.fighter.second-wind#second-wind` | `features.fighter.second-wind.actions.second-wind.name` | +| An action on a content type | `fate-core:stunts.psychologist#use` | `stunts.psychologist.actions.use.name`, else `types.stunt.use.name` | +| An action's choice option | option `hit` of `weapon-attack` | `actions.weapon-attack.options.hit.name` | +| An action's choice question | `needs` `{ action, step: 'hit/confirm' }` | `actions.weapon-attack.steps.confirm.prompt` | +| An action's roll (optional) | `needs` or log `{ action, step: 'damage' }` | `actions.weapon-attack.steps.damage.name` | +| An action's parameter | parameter `targetAc` of `weapon-attack` | `actions.weapon-attack.params.targetAc.name` | +| A parameter content adds | `element`, `addedBy` Hurl Element | `abilities.elementalist.hurl-element.params.element.name` (`types..…` a type's, `rules.…` the rules') | +| An action's result | `outcome.result: hit` of `weapon-attack` | `actions.weapon-attack.results.hit.name` | +| Longer text (optional) | an action, a field | `actions.weapon-attack.description`, `fields.hp.current.description` | +| A situational choice | `5e-srd-5.1:advantage` | `situational.advantage.name` | +| A result band | log `band: success`, `bandSet: …:action` | `bands.action.success.name` | +| A named text (optional) | a spell's type declares `texts: [material, trigger]` | `spells.shield.texts.trigger` | +| A copy's value (optional) | a cohort's computed `quality`, its state `harm` | `types.cohort.computed.quality.name`, `types.cohort.state.harm.name` | +| Content's computed value (optional) | a technique's `healBonus` in `activationValues` | `techniques.psychic-succor.computed.healBonus.name` | +| An aura (optional) | an aura entry's `source` and `aura` | `features.paladin.aura-of-protection.auras.protection.name` | +| A tag (optional) | tag `finesse` on a weapon | `tags.finesse.name`, `tags.finesse.description` | +| A card header (optional) | a spell, `5e-srd-5.1:spells.fire-bolt` | `spells.fire-bolt.block`, else `types.spell.block` | +| An enum value (optional) | a spell's `school: evocation` | `types.spell.fields.school.values.evocation.name` | +| The headline (optional) | the character, "Level 3 Hill dwarf Wizard" | `headline`; a member's piece `types.class.headline` | +| A choice on content | path `{ entry, choice: ['fighting-style'] }` on a fighter | `classes.fighter.choices.fighting-style.prompt` | +| A rules-level choice | path `{ choice: ['playbook'] }` | `rules.choices.playbook.prompt` | +| A rules-level choice's issue (`id`) | `fitd-scoundrel:rules/vice` | `rules.choices.vice.prompt`, in the pack the ID names | +| An inline option | option `disavowed` of a career's `incident` choice | `careers.agent.choices.incident.options.disavowed.name`, `.description` (optional) | +| A prerequisite (issue `id`) | `5e-srd-5.1:classes.fighter/multiclass` | `classes.fighter.prerequisites.multiclass` | +| A constraint (issue `id`) | `5e-srd-5.1:attunement-limit` | `constraints.attunement-limit` | Some details: @@ -125,7 +130,9 @@ Some details: `playbooks.bruiser.choices.special-ability.prompt` in its homebrew text. A choice ID with a slash names its owner itself: `5e-srd-5.1:features.wizard.spellcasting/cantrips` (a choice of granted content) is `features.wizard.spellcasting.choices.cantrips.prompt`. A rules-level choice's ID in a path is bare; - find its pack from `ruleset.rules.choices`, whose keys are qualified. + find its pack from `ruleset.rules.choices`, whose keys are qualified. Issues name it qualified + (`:rules/`, and an inline option of one `:rules//