Skip to content

v0.78.0 — powers that roll damage, actions that stream, and a modal that navigates itself

Three playtest reports against System 8 character sheets. They look unrelated and two of them share a cause: a capability was built, and the consumer that needed it most was never connected to it.

The chat streaming API is not missing. v0.73.0 shipped it and every piece survives — actionDoc.ts, actionDocSession.ts, api.chat.stream in the sandbox, sendMessage / applyDamage / requestRoll all routed through the document when a session exists. What is missing is adoption at both ends: five execution surfaces never pass actionDocDeps, so the same action compacts or does not depending on where it was clicked, and System 8's power generator emits zero api.chat.stream calls while 5e's emits them for saves.

The damage tiers are not missing either. light/medium/heavy exist, hardcoded inside the generated spellcasting-implement attack in characterToToken.ts, unreachable from any power. And the reason a campaign function "exists but fails on execution" is that campaign functions are compiled with six parameters where actions get thirteen — a helper that touches character, target or api compiles clean and throws when called, into a console.warn nobody reads.

Phase D is first because it is the smallest change and everything else wants a helper function. Phases E and F both edit createWikiAction; doing them in one pass is less work and less risk than two.

Phase D — the action sandbox has one parameter list

  • [x] D.1 — One parameter list, built once. actionExecutor compiles actions with thirteen names (token, character, roll, sendMessage, rollDetailed, rollSilent, prompt, target, targets, damageTypes, draw, area, api) and campaign functions with six. A campaign function therefore cannot see character — which any damage helper needs for attribute modifier, rank and hit power — and dies at call time on a name that was never bound. One builder produces the names and values; actions and functions both use it. This is the whole of "the functions exist but fail on execution".
  • [x] D.5 — Token-scoped functions have never worked at all. Found by D.1's own test. sandbox.token is a Proxy whose set trap permits metadata and refuses everything else — so (sandbox.token)[fn.name] = boundFn threw in strict mode, and the compile try/catch wrapped around it swallowed the throw. Every token-type campaign function ever written has silently not existed. They are registered in a map the get trap consults instead, which leaves the write protection exactly as it was. Listed after D.1 because that is the order they were found in, not the order they are fixed in.
  • [x] D.2 — A failed function reaches the player, not the console. Both compile sites swallow the error into console.warn, which is why a broken helper looks like a broken action. A compile or call failure becomes an error step in the action's chat document.
  • [x] D.3 — Systems can contribute functions, not just campaigns. ICampaignFunctionRef has functionType: 'global' | 'token', both campaign-scoped; there is no system scope at all. Add systemFunctions to the module contract (Module API bump), injected by the same builder. Campaign overrides system on a name clash — a table retuning a formula is the reason to have two scopes, and it needs stating or nobody can rely on it.
  • [x] D.4 — System 8 ships lightDamage / mediumDamage / heavyDamage. The three formulas move out of characterToToken.ts into the module's systemFunctions, and the implement attack calls them instead of inlining them. Removes the duplicate before a second copy exists rather than after.

Phase E — powers roll damage

  • [x] E.1 — A damage: convention in the docs' frontmatter. Targeting is already declarative (range, target, save); damage is the one mechanic still inferred from prose wording. Of 575 action documents, 326 mention no damage at all, 249 do, and 196 of those state a plain tier — the exceptions are 21 ongoing, 11 literal dice, 2 doubled and 2 percent-of-max-HP. So: a scalar shorthand (damage: medium) for the common case, a long form carrying tier / multiplier / type / ongoing, and an expression: escape hatch for the handful that are none of those. Absent means no damage, not unknown — 326 documents depend on that reading.

  • [x] E.1b — damage.type is an OPEN set, keyed case-insensitively. Decided: no validation against a closed list, and a power may imply a new type. The canonical doc (system/damage/damage-types, not in the bundle) is prose introduced by "common damage types include", so a closed union would have been wrong anyway. Also settled: the multiplier applies to (dice + constant), not the dice alone; ongoing accepts a round count or a save that ends it (Con ends); and actions may declare minRank, ahead of the documents, because the next edition keeps featless rank-ups even as feats replace most of this progression.

  • [x] E.1c — minRank is parsed and carried, deliberately not enforced. It survives both zod schemas (characters.ts and tokens.ts — an undeclared field is silently stripped, which is how this was nearly shipped as a no-op) and reaches the generated action. Nothing acts on it, because no document declares one yet: hiding or disabling an action below its rank is a UI decision, and making that decision against zero real data would be inventing the rule rather than reading it. The field exists so the gate has somewhere to live when the documents catch up.

    The rest of this item was the damage-type question, and E.1b settled it: the list is
    open, so nothing needs to widen the ingest to reach `system/damage/damage-types`. Tier
    formulas are campaign-overridable, which D.3's campaign-over-system precedence already
    implements — no change needed there either.
    
  • [x] E.2 — Proposed once, by machine, reviewed by hand. propose-damage-frontmatter.mjs in the system8 repo writes a review file and edits no document — damage is a rules statement; a machine may read it and a person signs it off. Patterns rather than an LLM: the corpus is regular, so the pass is deterministic, auditable and re-runnable, and a disagreement is a diff rather than a fresh opinion each run. 118 high confidence · 35 need a human · 85 mention damage but declare none · 13 use a tier as a MAGNITUDE · 324 no damage. That last bucket is the one worth knowing about: "temporary hit points equal to a heavy damage roll" is not a document that deals heavy damage, and taking those at face value would have armed thirteen shield and summon effects. Still to do: the human pass.

  • [x] E.3 — createWikiAction emits a damage roll. Driven by damage:, calling the Phase D system functions rather than inlining a formula.

  • [x] E.4 — The ingest reports prose-without-frontmatter. A document whose text says "damage" but which carries no damage: block is named at build time — a warning, not a failure, exactly like the dangling-action references. Without it the convention decays the first time someone writes a power in a hurry.

Phase F — generated actions stream

  • [x] F.1 — The four unwired surfaces opt in. TokenContext, CurrentTokenBar, FloatingTokenWindow and TokenModal passed campaignFunctions but not actionDocDeps, so an action fired from any of them silently took the legacy multi-message path. Four, not five: TestActionPanel calls testAction, which creates no session at all, so deps would be inert there — the exemption is about which executor is called, not about the surface mattering less. useActionDocDeps's own header enumerates "SEVEN surfaces", which is how many the author knew about.
  • [x] F.2 — A guard test, because this gap will otherwise recur. Any call site building an ActionExecutionContext with campaignFunctions must also supply actionDocDeps.
  • [x] F.3 — One emitter for the streaming preamble. specToActions.ts and characterToToken.ts each hand-wrote the hasDocs guard and a stream() helper; a third copy was not the way to add System 8. actionStreamPreamble() / streamSlot() / suppressLegacyReturn() in packages/shared. characterToToken is refactored onto it and derivePowerActions is written against it. Two copies remain, both in modules: system8's projectToken and dnd5e's specToActions cannot import a helper from an unreleased @purple-cat/shared, since module CI resolves it against platform main. They follow once this ships. Behaviour is identical meanwhile — the divergence is cosmetic.
  • [x] F.4 — System 8 power actions stream. attack, saves and damage slots in createWikiAction, so a multi-target power updates in place instead of appending a message per target. This is the case the 24-chat-log-compactness spec opens with and the one nothing implements.
  • [x] F.5 — Aggregate multi-target saves into one slot, ported from 5e rather than invented. System 8 carries no save DC in its metadata, so the tally counts declined versus answered where 5e counts passed versus failed — it cannot know which rolls succeeded, and inventing a threshold would be worse than reporting the rolls.
  • [x] F.6 — Write the rule down. Generated actions stream; custom actions may. Into the Action API doc, or the next generator will not.

Phase G — the detail modal navigates itself

  • [x] G.1 — Documents stop showing wiki chrome — at RENDER, not at ingest. rewriteWikiLinks sends the breadcrumb trail off to the wiki because it is the one link shape it does rewrite. I planned to strip it during ingest; on measuring, that is the worse of the two. It saves 18% of a document (3224 → 2638 bytes on Hellfire) but costs a re-ingest and a 1,154-file diff in this release, and it makes the bundle stop matching upstream, which is what makes a content change diff cleanly. The viewer already walks the HTML to intercept links, so stripping there is the same one place with none of that.
  • [x] G.2 — Resolve a document link against the document showing it. Action links are relative (../../actions/powers/warlock/immolate.md), and rewriteWikiLinks only matches href="/…" — so they resolve against map.purplecat.games and 404. The resolution rule already exists twice (resolveActionUrl, toSystemDocUrl); this is one shared helper that also accepts .md.
  • [x] G.3 — A document viewer with a stack. Clicking a link inside the modal slides to the linked document instead of navigating: one delegated click handler, a key stack, a transform transition that respects prefers-reduced-motion. A link that resolves to no bundled document keeps its current behaviour rather than being swallowed.
  • [x] G.4 — Back is the browser's Back. pushState per push with the depth in state, popstate pops. The URL itself must not change or the SPA router will try to route it. X closes the modal outright and cleans up its entries.
  • [x] G.5 — The three current consumers share it. TraitSection was wired first — the case in the report. PlayModeView and the Add-Power/Feat expanded panels rendered their own dangerouslySetInnerHTML, and the pickers additionally needed a systemId they were never given — the same gap that made their detail panel read "No additional detail available" for every entry. They take module and derive the id from it, because the sheet is barred from naming a system and module.id is the answer (see the earlier note); passing systemId directly is what the contract test forbids. renderBody is a required prop because the three surfaces do not agree on the trust boundary: this one routes everything through ActionDescription's DOMPurify pipeline, deliberately and documented at the call site, while the other two set HTML directly. A viewer that picked one would silently downgrade the other.

Phase H — the tiers reach the sandbox, and a stale action notices

Playtest of Phase E against a live character, and the damage never rolled. Two causes, both in code this release shipped, and both the same shape as the gap Phase F closed: a capability was built and the surfaces that need it were never connected to it.

The damage helpers are unreachable from nine of ten surfaces. resolveScriptFunctions resolves the system from context.character?.systemId, falling back to context.token.metadata.systemId. Only ONE of the ten builders of an ActionExecutionContext passes character — CurrentTokenBar even computes linkedCharacterId two lines above the context literal and omits it — and nothing has ever written metadata.systemId, so the fallback falls back to nothing. D.3 resolves a system correctly and D.4's tiers exist; a power fired from the impersonation bar still dies on mediumDamage is not defined.

A derived action is a snapshot, and nothing re-takes it. derivePowersForCharacter skips any power that already has actions unless force, so a power derived before its document grew a damage: block keeps the damage-less code forever. "Refresh from Character" cannot fix it: it re-projects the character onto the token and never re-derives, so it faithfully copies the stale actions across. Deleting and re-adding the power works, which is the tell.

  • [x] H.1 — A projected token carries its systemId. Stamped in projectCharacterToken and tokenFromGeneratedCharacter — the two projection entry points — rather than in each system's projectToken, so it holds for the legacy converter and for a system whose module predates the field. Platform-side on purpose: no module release stands between the fix and the bug.

  • [x] H.2 — Every action context passes character. The nine builders that omit it, plus a guard test in the shape of F.2's: a call site building an ActionExecutionContext with campaignFunctions must also resolve character. H.1 alone leaves existing placed tokens broken until each is refreshed; this alone leaves monsters and unlinked tokens broken. Both, or the hole moves rather than closes.

  • [x] H.3 — A derived action records the content it came from. The ingest already writes data/provenance.json and the server already reads it. Stamping that commit onto each derived action turns "is this stale?" into a comparison instead of a re-derivation, and says why when it is.

  • [x] H.4 — Impersonation trues up what H.3 says is stale. Asynchronous and fire-and-forget — picking up a token must not wait on a derivation. Only powers whose stamp disagrees with the loaded system's provenance, so the common case costs one comparison.

  • [x] H.8 — The System dropdown says which version of the rules you are getting. The Edit Campaign modal names the system and describes it, and says nothing about WHICH build of it is loaded — so "is my damage frontmatter live?" is a question only the admin page can answer. GET /api/systems carries the module version and, where the system declares one, the bundled-content provenance the ingest writes (data/provenance.json: version, commit, branch, channel). The panel renders what each system actually has rather than a fixed pair of lines: a system with no bundled content shows its module version alone, which is the whole of the answer for one that ships its rules in the package.

    Nothing here names a system — the registry already records which module supplies which
    system's content, and CC-1's guard is the reason to ask it rather than to branch.
    **The 5e SRD version is NOT included**: that module bundles its SRD but declares no
    `provenance`, and reading its `srdVersion()` would mean either a module release or the
    platform special-casing a system id. The first is right and is a separate change.
    
  • [x] H.9 — The breadcrumb bar behaves like a breadcrumb. Each section's whole button is the menu target, so clicking a campaign's name opens a picker instead of going to the campaign — the one thing the name looks like it does. The name and the caret become separate controls: the name navigates to that section's own page, the caret opens the menu.

    `sectionHomePath` already computes the destination and already answers null when you are
    on it, so all three sections get this from the function v0.57.0 wrote for the menu
    entry — and the section you are IN keeps a name that is plain text rather than a link to
    where you already are.
    
  • [x] H.7 — A doc stops saying "awaiting approvals" when nothing is awaited. actionDocSession.finish() sets awaiting when the script ends with damage cards outstanding, and that was the last word on the subject: handleDocDamageAction patched the resolved STEP and stopped, so approving or denying every card left the header reading "awaiting approvals" indefinitely. The summary line beside it was already correct — it counts pending steps — which is what made the header look like a rendering quirk rather than an unwritten field.

    `settledDocStatus` transitions **only** `awaiting`: `running` belongs to `finish()`
    (approving a card mid-run must not close a doc still producing steps), `error` outranks
    pending approvals there and must keep doing so here, and the rest are already final. It
    decides from the steps as they will be after the patch, via `stepsWithPatch` — deciding
    from the doc as rendered would read the very card being resolved and never settle.
    
    **Not extended to `ask` steps**, though the status comment mentions them: `finish()` has
    never set `awaiting` for an outstanding ask, so teaching this half about asks would
    strand docs in the state this item exists to clear. Worth a look, separately.
    
  • [x] H.6 — The generated damage roll AWAITS the tier helper. Every contributed script function is compiled by compileScriptFunction as an AsyncFunction and wrapped in an async error-annotating closure, so mediumDamage(3, 3, 0) is a Promise however arithmetic it looks. The generated code assigned it without awaiting, handed it to roll(), and died inside the dice roller on expression.match is not a function — an error naming neither the power nor the helper, under a chat line reading rolling [object Promise]….

    The reason this survived H.1–H.5 is the shape of its test: `derivePowerActions.test.ts`
    asserted the generated code CONTAINED `mediumDamage(3, 3, 0)` and `await roll(dmgExpr)`.
    Both were true. Both are still true of the broken version. No assertion about the text of
    generated code could have caught it, so the fix comes with
    `actionExecutor.derivedDamage.test.ts`, which runs real derivation output through the
    real executor against a real async helper and reads the damage number off the
    `[DAMAGE_PENDING:…]` message — verified to reproduce the reported error when the `await`
    is removed.
    
  • [x] H.5 — "Refresh from Character" re-derives first. It is the control whose name promises this, and it is the one that silently could not deliver it. Force a re-derive, then project — in that order, or it projects what it is about to replace.