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.
actionExecutorcompiles 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 seecharacter— 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.tokenis a Proxy whosesettrap permits metadata and refuses everything else — so(sandbox.token)[fn.name] = boundFnthrew in strict mode, and the compiletry/catchwrapped around it swallowed the throw. Every token-type campaign function ever written has silently not existed. They are registered in a map thegettrap 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.
ICampaignFunctionRefhasfunctionType: 'global' | 'token', both campaign-scoped; there is no system scope at all. AddsystemFunctionsto 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 ofcharacterToToken.tsinto the module'ssystemFunctions, 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 carryingtier/multiplier/type/ongoing, and anexpression: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.typeis 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;ongoingaccepts a round count or a save that ends it (Con ends); and actions may declareminRank, ahead of the documents, because the next edition keeps featless rank-ups even as feats replace most of this progression. -
[x] E.1c —
minRankis parsed and carried, deliberately not enforced. It survives both zod schemas (characters.tsandtokens.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.mjsin 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 —
createWikiActionemits a damage roll. Driven bydamage:, 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,FloatingTokenWindowandTokenModalpassedcampaignFunctionsbut notactionDocDeps, so an action fired from any of them silently took the legacy multi-message path. Four, not five:TestActionPanelcallstestAction, 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
ActionExecutionContextwithcampaignFunctionsmust also supplyactionDocDeps. - [x] F.3 — One emitter for the streaming preamble.
specToActions.tsandcharacterToToken.tseach hand-wrote thehasDocsguard and astream()helper; a third copy was not the way to add System 8.actionStreamPreamble()/streamSlot()/suppressLegacyReturn()inpackages/shared.characterToTokenis refactored onto it andderivePowerActionsis written against it. Two copies remain, both in modules: system8'sprojectTokenand dnd5e'sspecToActionscannot import a helper from an unreleased@purple-cat/shared, since module CI resolves it against platformmain. They follow once this ships. Behaviour is identical meanwhile — the divergence is cosmetic. - [x] F.4 — System 8 power actions stream.
attack,savesanddamageslots increateWikiAction, 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.
rewriteWikiLinkssends 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), andrewriteWikiLinksonly matcheshref="/…"— so they resolve againstmap.purplecat.gamesand 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.
pushStateper push with the depth instate,popstatepops. 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.
TraitSectionwas wired first — the case in the report.PlayModeViewand the Add-Power/Feat expanded panels rendered their owndangerouslySetInnerHTML, and the pickers additionally needed asystemIdthey were never given — the same gap that made their detail panel read "No additional detail available" for every entry. They takemoduleand derive the id from it, because the sheet is barred from naming a system andmodule.idis the answer (see the earlier note); passingsystemIddirectly is what the contract test forbids.renderBodyis a required prop because the three surfaces do not agree on the trust boundary: this one routes everything throughActionDescription'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 inprojectCharacterTokenandtokenFromGeneratedCharacter— the two projection entry points — rather than in each system'sprojectToken, 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 anActionExecutionContextwithcampaignFunctionsmust also resolvecharacter. 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.jsonand 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/systemscarries 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()setsawaitingwhen the script ends with damage cards outstanding, and that was the last word on the subject:handleDocDamageActionpatched 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
compileScriptFunctionas anAsyncFunctionand wrapped in an async error-annotating closure, somediumDamage(3, 3, 0)is a Promise however arithmetic it looks. The generated code assigned it without awaiting, handed it toroll(), and died inside the dice roller onexpression.match is not a function— an error naming neither the power nor the helper, under a chat line readingrolling [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.