From 6a810dcf21346e410290d20009ad6ed6165d737b Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 17:36:36 +0800 Subject: [PATCH 01/25] docs: design full mdc combat exchange --- .../2026-07-21-mdc-combat-exchange-design.md | 593 ++++++++++++++++++ 1 file changed, 593 insertions(+) create mode 100644 .codex/superpowers/specs/2026-07-21-mdc-combat-exchange-design.md diff --git a/.codex/superpowers/specs/2026-07-21-mdc-combat-exchange-design.md b/.codex/superpowers/specs/2026-07-21-mdc-combat-exchange-design.md new file mode 100644 index 0000000..a52d125 --- /dev/null +++ b/.codex/superpowers/specs/2026-07-21-mdc-combat-exchange-design.md @@ -0,0 +1,593 @@ +# Full M.D.C. Combat Exchange Design + +**Date:** 2026-07-21 +**Status:** Approved in conversation +**Branch:** `feat/mdc-combat-exchange` +**Primary issue:** [#51 — Full M.D.C. combat](https://github.com/StreamDemon/RiftForge/issues/51) + +## Goal + +Extend the persisted hostile-combat vertical slice from Issue #44 through the +complete core S.D.C./M.D.C. damage-tier boundary without inventing optional +injury rules or introducing a generic effect interpreter. + +This slice will: + +- support owned S.D.C. and M.D. weapon attacks through one exchange protocol; +- apply the printed S.D.C.-to-M.D.C. conversion and imperviousness rules; +- resolve S.D.C. and M.D. attacks against S.D.C. and M.D.C. body armor; +- preserve the final-blast absorption rule for destroyed armor; +- preserve the limited S.D.C. protection of depleted M.D.C. body armor; +- resolve M.D. against an unprotected mortal and persist deterministic death; +- keep all dice, conversion, routing, and writes authoritative on the server; +- keep existing Issue #44 exchange history readable without a data rewrite; and +- present the complete route in the live SolidJS combat rail. + +The existing exchange remains the authority boundary. Issue #51 does not add +M.D. input to the sheet's manual damage control. + +## Approved decisions + +1. **One exchange protocol.** Extend the existing typed exchange model. Do not + build a parallel M.D.C. ledger or resolver. +2. **Core outcomes only.** M.D. against an unprotected mortal follows the core + damage and death rules. The optional near-fatal injury procedure on RUE + pp.358-359 is deferred to Issue #54. +3. **Exchange-only M.D. writes.** M.D. can enter persistent character state only + through the server-rolled exchange. The manual damage control remains S.D.C. +4. **No generic effects layer.** Issue #51 remains feature-specific. A future + page-stamped effect pipeline is tracked in Issue #53 and has explicit entry + criteria. +5. **Backward-compatible history.** Existing resolved, cancelled, stale, and + pending Issue #44 records remain accepted. Legacy pending records may safely + stale when their page-stamped rules token no longer matches. +6. **Terminal death is explicit.** Fatal overflow cannot be represented as a + survivable character clamped to the coma floor. A persisted terminal marker + distinguishes death from a character exactly at the legal negative H.P. + floor. +7. **No client-authored tier.** Weapon content determines S.D.C. versus M.D.; the + UI never submits a damage tier or conversion. + +## Rendered rules evidence + +The scanned local Rifts Ultimate Edition has no usable text layer. The following +pages were rendered from PDF indexes `printed page + 2` at 2.2x and inspected +visually on 2026-07-21. + +| Printed page | Rule used in this design | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 287 | Artificial S.D.C. armor routes every completed strike by A.R.: at or below A.R. the armor absorbs the attack; above A.R. the attack reaches the wearer. Depleted S.D.C. armor affords no future protection. | +| 288 | One M.D. equals 100 S.D.C. M.D.C. armor is impervious to S.D.C./Hit Point damage below 100. S.D.C. totals of 100 or more can harm M.D.C.; divide by 100 and round down. M.D.C. armor has no A.R. The last M.D.C. absorbs the complete destroying blast. | +| 354-355 | Zero or negative H.P. is the coma band down to `-P.E.` inclusive. Damage beyond that floor is death with no hope of recovery. The final M.D.C. of armor absorbs the complete destroying blast; subsequent M.D. attacks reach the unprotected wearer. | +| 355 | At zero M.D.C., body armor is scrap but still stops S.D.C. strike totals 1-7; totals of 8 or higher reach the body inside. Ordinary S.D.C. weapons do not harm M.D.C. beings except for printed vulnerabilities. | +| 356-357 | Context only: M.D.C. technology, equipment scarcity, and GM guidance. No new deterministic damage constant is introduced from these pages. | +| 358 | Core text describes M.D. against an S.D.C. body as normally lethal. The near-fatal survival procedure begins as an optional guideline requiring GM agreement and medical intervention. | +| 359 | The optional survival path introduces hit locations, called-shot eligibility, limb/internal injury, immediate care, surgery, and trauma. Those mechanics are deliberately outside Issue #51 and tracked in Issue #54. | + +Existing Issue #44 sources remain authoritative for declaration, defense, +critical multiplication, ranged combat, S.D.C. armor A.R., and S.D.C.-before-H.P. +routing. + +## Scope + +### In scope + +- Owned S.D.C. knife, axe, handgun, and submachine-gun attacks already supported + by Issue #44. +- Owned M.D. energy-pistol and energy-rifle attacks from the current catalog. +- Existing melee/ranged declaration context, defense authorization, critical + rules, and server-owned dice. +- S.D.C. attacks against intact and depleted M.D.C. body armor. +- M.D. attacks against S.D.C. armor, intact M.D.C. armor, depleted M.D.C. + armor, and an unprotected S.D.C./Hit Point body. +- Final-blast armor absorption without spill into the wearer. +- Explicit stopped, armor, body, and fatal route evidence. +- Persisted terminal death state and derived alive/coma/dead presentation. +- Consistent fatal-threshold handling for the existing manual S.D.C. damage + mutation, without adding an M.D. input mode. +- Backward-compatible Convex validators and history formatting. +- Live two-dossier combat acceptance and parameter-route ownership checks. + +### Out of scope + +- Optional near-fatal M.D. survival, hit locations, limb loss, medical checks, + surgery, trauma, and bionic reconstruction (Issue #54). +- A generic spell/psionic/combat effect pipeline (Issue #53). +- General called shots, aimed locations, bursts, payload tracking, thrown weapon + modes, initiative, rounds, or action-budget enforcement. +- M.D.C. creatures, supernatural bodies, dragons, vehicles, robot vehicles, + power-armor operation, force fields, and printed vulnerability exceptions. +- Explosions, blast radii, impact damage, knockdown, cover geometry, and VTT + positioning. +- Authentication, ownership, GM permissions, resurrection, or character deletion + policy. +- Manual M.D. damage entry. + +## Architecture + +The existing authority flow remains intact: + +```text +page-stamped tier constants + pure exchange resolver + | + v +Convex exchange ledger + atomic character write + | + v +SolidJS declaration / response / history rail +``` + +Issue #51 expands the types and route calculation inside each boundary. It does +not add a fourth orchestration layer. + +### Rules package + +The pure rules layer owns: + +- damage-tier classification from weapon content; +- page-stamped S.D.C./M.D.C. conversion constants; +- protection classification, including depleted M.D.C. armor identity; +- critical multiplication before tier conversion; +- stopped, armor, body, and fatal route calculation; +- life-state derivation and terminal-state invariants; and +- stable combat-state tokens over every route-relevant input. + +Random rolls, elapsed time, completed strike/defense rolls, and current pools are +inputs. The rules layer never reads Convex or mutates a character. + +### Backend + +Convex continues to own exchange identity, synchronization, random dice, +immutable history, current-state rederivation, and the exactly-once character +write. It persists the rules layer's authorized route rather than reproducing +tier math. + +### Web + +SolidJS surfaces server-derived weapon/target choices, declaration context, +authorized defenses, and immutable route evidence. It never converts S.D.C. to +M.D.C., decides death, or calculates armor damage. + +## Page-stamped combat content + +The combat exchange content and Zod schema gain explicit printed references and +constants for: + +- M.D.C. introduction: p.288; +- M.D.C. combat: p.355; +- S.D.C. per M.D.: `100`; +- minimum S.D.C. total that can harm intact M.D.C.: `100`; +- depleted M.D.C. body-armor bypass strike total: `8`; and +- final-blast absorption: enabled for the last positive armor point. + +The schema pins literal printed values and rejects contradictory shapes at import. +Tests assert the complete content object so page or constant drift fails loudly. + +## Pure rules model + +### Attack profiles + +`AttackProfile` expands its supported branch from `damageType: "sdc"` to +`damageType: "sdc" | "md"`. + +| Weapon category | Attack kind | Tier | +| ----------------------------- | ----------- | ------ | +| `knife`, `axe` | melee | S.D.C. | +| `handgun`, `submachineGun` | ranged | S.D.C. | +| `energyPistol`, `energyRifle` | ranged | M.D. | + +Existing strike bonuses, ranged minimums, defense options, critical thresholds, +and firearm restrictions apply unchanged. M.D. weapons do not receive invented +bonuses or special defense behavior. + +The attack snapshot and attacker state token include the damage tier and all +page-stamped conversion constants. Any weapon/content change invalidates a pending +exchange before additional dice or writes. + +### Protection classification + +Protection remains derived from the worn physical item: + +- `none`; +- `sdcArmor` with A.R., maximum, and current S.D.C.; or +- `mdcArmor` with maximum and current M.D.C. + +Unlike Issue #44, a worn M.D.C. suit at zero remains `mdcArmor`. Printed p.355 +gives depleted body armor limited S.D.C. protection, so treating it as `none` +would lose a real mechanic and its identity. + +A dice-capacity M.D.C. suit whose per-instance maximum has not been rolled is +classified as unready. It can be displayed, but no attack may be declared against +it until its maximum/current protection is known. + +### Resolution order + +1. Validate the attack snapshot and combat context. +2. Validate the completed strike roll and declaration minimum. +3. Resolve an authorized defense with the existing `resolveStrike` primitive. +4. Determine critical state and multiplier. +5. Validate and total the native weapon damage roll. +6. Apply the critical multiplier in the weapon's native tier. +7. Classify current protection, including a depleted M.D.C. shell. +8. Convert tiers only when the route requires conversion. +9. Apply the route without spill from a destroying armor hit. +10. Return immutable before/after evidence and terminal outcome. + +Conversion never happens before the critical multiplier. This makes the value +being converted the actual completed damage total. + +### Routing matrix + +| Attack | Protection | Pure result | +| ------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S.D.C. | None | Apply to personal S.D.C., then H.P.; classify survivable versus fatal overflow. | +| S.D.C. | S.D.C. armor | Existing strike-vs-A.R. route: at/below A.R. ablates armor; above A.R. reaches body. Destroying armor absorbs the full hit. | +| S.D.C. | Intact M.D.C. armor | Under 100 is stopped with no pool change. At 100+, apply `floor(total / 100)` M.D. to armor. Destroying armor absorbs the full hit. | +| S.D.C. | Depleted M.D.C. armor | Completed strike totals 1-7 are stopped. Totals 8+ reach personal S.D.C./H.P. as S.D.C. | +| M.D. | None | Convert completed M.D. to `M.D. * 100` S.D.C./H.P. damage and classify fatal overflow. | +| M.D. | S.D.C. armor | Apply the existing A.R. comparison. At/below A.R., convert to S.D.C. and ablate armor with no destroying-hit spill. Above A.R., convert the full hit to personal S.D.C./H.P. damage and classify fatal overflow without changing armor. | +| M.D. | Intact M.D.C. armor | Apply native M.D. to armor. The destroying hit does not spill into the body. | +| M.D. | Depleted M.D.C. armor | Convert the full completed M.D. total to S.D.C./H.P. damage and classify fatal overflow. | + +An intact M.D.C. hit by 99 S.D.C. yields a persisted stopped route. A hit by 450 +or 496 S.D.C. applies 4 M.D., matching the printed round-down examples. A hit by +21 M.D. against 3 remaining M.D.C. changes armor from 3 to 0 and leaves body +pools untouched. A subsequent M.D. hit reaches the wearer. + +### Versioned route evidence + +Existing Issue #44 routes remain valid in their original shape. New routes carry +a required routing version so the validator and UI can distinguish legacy and +tier-aware evidence without guessing from optional fields. + +A tier-aware route records: + +- native completed damage value and tier; +- converted damage value and tier when conversion occurred; +- route kind: `stopped`, `armor`, `body`, or `fatal`; +- stable stopped reason, when applicable; +- armor identity and before/after pool, when applicable; +- body S.D.C./H.P. before and after, when applicable; +- whether final-blast absorption prevented spill; and +- life state before and after for fatal routes. + +`totalDamage` remains the completed native weapon total. Converted/application +values live in route evidence so old S.D.C. records do not change meaning. + +### Life state and fatal overflow + +Character storage gains `current.lifeState?: "dead"`. Absence means the +character is not terminally dead and remains backward-compatible with every +existing document. + +The derived sheet exposes: + +- `alive` when current H.P. is above zero; +- `coma` when current H.P. is zero through `-P.E.` inclusive and no terminal + marker exists; and +- `dead` when the terminal marker exists. + +Body damage calculates the raw H.P. result before enforcing the storable numeric +floor: + +- raw H.P. at or above `-P.E.` is survivable and stores the actual value; +- raw H.P. below `-P.E.` is fatal, stores H.P. at `-P.E.`, stores personal S.D.C. + at zero, and sets `lifeState: "dead"`. + +The terminal marker is valid only when rolled vitals exist, current personal +S.D.C. is zero, and current H.P. equals the derived `-P.E.` floor. These +contradictions are rejected by `deriveSheet`, so no mutation can store a dead +character with healthy pools. + +Death is terminal in current product scope. Existing damage, healing, treatment, +rest, inventory, and combat mutations must preserve the marker or reject the +operation. Only a separately designed resurrection mechanic may clear it. + +The existing manual damage mutation remains S.D.C.-only but uses the same raw +overflow classifier. This fixes the existing case where arbitrarily large S.D.C. +damage could clamp to the coma floor and remain survivable. + +## Persisted exchange model + +The existing discriminated exchange variants remain: + +- `pendingDefense`; +- `resolved`; +- `cancelled`; and +- `stale`. + +The base attack snapshot expands `damageType` to the tier union. The resolved +result validator accepts both the legacy Issue #44 route and the new versioned +tier-aware route. + +No historical rewrite is required: + +- old characters lack `current.lifeState` and derive as nonterminal; +- old attack snapshots already contain `damageType: "sdc"`; +- old resolved routes remain accepted by their legacy validator branch; and +- old pending exchanges whose state tokens no longer match resolve safely to + `stale`, never through legacy rules. + +## Combat-state tokens + +Attacker tokens continue to fingerprint the selected weapon and derived attack. +They additionally cover damage tier and the page-stamped M.D.C. constants. + +Defender tokens continue to fingerprint rolled/current body pools and complete +worn-protection identity. They additionally cover: + +- derived life state; +- the terminal marker; +- depleted M.D.C. armor identity and zero pool; and +- armor readiness for a dice-capacity suit. + +Narrative, P.P.E., and unrelated inventory entries remain excluded so irrelevant +edits do not stale combat. + +## Backend operations + +### Target query + +The bounded target query continues to exclude self and report readiness. It now +also reports: + +- derived life state; +- protection tier and current/max pool; +- whether a dice-capacity M.D.C. suit still needs its roll; and +- a stable disabled reason for dead or protection-unready targets. + +M.D.C. protection is no longer itself a disabled reason. + +### Declare attack + +Declaration continues to: + +1. load and rederive attacker and defender; +2. reject self, missing, unready, or dead combatants; +3. derive the selected owned weapon profile; +4. derive current protection and require any per-suit armor roll; +5. parse explicit combat context; +6. roll the strike on the server; +7. persist an immediate miss or a pending defense; and +8. return only stable exchange/result data. + +M.D. weapon modes are no longer refused. No damage die is rolled for an immediate +miss. + +### Respond to attack + +Response retains the exactly-once transaction: + +1. load a pending exchange; +2. rederive both combatants; +3. compare weapon, attack, context, defense options, state tokens, life state, and + current protection; +4. parse and authorize the selected response; +5. roll defense when required; +6. roll native damage only for a hit; +7. resolve the tier-aware route; +8. atomically patch armor/body/life state when changed; and +9. replace pending history with the immutable resolved result. + +Any changed route-relevant state finalizes the exchange as stale before defense or +damage dice and before character writes. + +### Existing character mutations + +Every mutation that can change resources or combat readiness is audited against +the terminal-state invariant. Dead characters cannot: + +- take additional manual damage; +- heal or receive battle-injury treatment; +- rest or restore resources; +- declare, receive, or respond to a live combat action as an active combatant. + +Read queries and immutable combat history remain available. A pre-existing +pending exchange involving a newly dead combatant may still be cancelled as +ledger cleanup; any attempted resolution finalizes it as stale before dice or +character writes. Inventory management does not clear or bypass the terminal +marker. + +## Web experience + +### Weapon and target selection + +Energy pistols and rifles become selectable and keep explicit `M.D.` units in +their labels. Tier is informational and cannot be edited. + +M.D.C.-armored targets become legal. A target with an unrolled dice-capacity suit +is disabled with a precise instruction to roll the suit's M.D.C. first. Dead +characters are disabled with terminal-state text. + +### Declaration and response + +The existing GM-context and defense forms remain unchanged. M.D. attacks use the +same ranged awareness, dodge, automatic-dodge, modifier, and response authority as +their weapon category requires. + +No new conversion, armor, or fatality controls appear. These are outputs. + +### Result presentation + +The recent-history formatter renders explicit route evidence, including examples +such as: + +```text +96 S.D.C. -> M.D.C. ARMOR IMPERVIOUS - NO EFFECT +250 S.D.C. -> 2 M.D. :: ARMOR 70 -> 68 +21 M.D. :: ARMOR 3 -> 0 :: FINAL BLAST ABSORBED +1 M.D. -> 100 S.D.C. :: UNPROTECTED BODY :: FATAL +DEPLETED SHELL STOPPED STRIKE 7 +DEPLETED SHELL BYPASSED :: BODY S.D.C. 12 -> 4 +``` + +Legacy Issue #44 results continue through their existing formatter branch. + +Fatal outcomes use the existing blood-red signal and explicit text such as +`LIFE SIGNS TERMINATED`. M.D.C. is technological, not magical, so it never uses +ley cyan. Stopped/no-effect armor results use the machine's amber voice; defended +results retain confirmed green. + +### Terminal dossier state + +The dossier remains navigable and readable after death. Vitals show the terminal +state without relying on color alone. Damage, healing, treatment, rest, and combat +controls are unavailable with a concise explanation. Historical exchanges remain +visible. + +### Navigation and asynchronous ownership + +The Issue #44 ownership model remains mandatory: + +- parameterized routes do not remount; +- route ID changes reset weapon/target/context drafts, incoming/outgoing/recent + feeds, notices, telemetry, and row-local state; +- in-flight declaration, response, and cancellation results carry route ID plus + monotonic epoch; and +- results are ignored when ownership no longer matches. + +## Error handling + +Stable structured failures cover: + +- dead attacker or defender; +- missing per-suit M.D.C. roll; +- changed weapon or attack tier; +- changed armor identity, pool, or life state; +- illegal context or defense; +- missing combatant or exchange; and +- non-pending exchange operations. + +The following are successful persisted outcomes, not errors: + +- S.D.C. below the intact-M.D.C. threshold; +- depleted shell stopping a low S.D.C. strike; +- final-blast absorption; +- a survivable coma result; and +- a deterministic fatal result. + +## Migration and compatibility + +Convex schema deployment adds optional character state and expanded union +branches. No data rewrite or backfill runs. + +Compatibility requirements: + +- existing characters derive without a terminal marker; +- existing resolved S.D.C. history remains queryable and renderable; +- new code accepts old cancelled and stale variants; +- legacy pending exchanges can be cancelled or safely become stale; and +- current indexes remain sufficient because exchange identity/status ownership + does not change. + +## Testing strategy + +### Rules package + +Pin printed constants and page stamps, then test the complete matrix: + +- 99, 100, 199, and 200 S.D.C. against intact M.D.C.; +- the printed 450 and 496 S.D.C. round-down examples; +- native M.D. against intact and nearly depleted M.D.C.; +- final-blast absorption for native and converted damage; +- depleted-shell completed strike totals 7 and 8; +- M.D. at/below and above S.D.C. armor A.R., including no destroying-hit spill; +- M.D. against an unprotected body; +- exact `-P.E.` survival versus one point beyond fatal overflow; +- critical multiplication before conversion; +- malformed or mismatched native/converted route evidence; +- life-state invariants and terminal mutation inputs; +- old S.D.C. route compatibility; and +- state-token sensitivity for tier, rules, depleted armor, and death. + +### Backend + +Verify: + +- target readiness for fixed, rolled, unrolled, intact, depleted, and dead state; +- M.D. attack declaration through the existing exchange API; +- no defense/damage dice or writes for invalid declarations; +- authoritative native damage rolls and conversions; +- atomic armor/body/death updates; +- final-blast no-spill; +- immutable stopped and fatal history; +- stale-state rejection before rolls/writes; +- two concurrent responses produce one winner and one character write; +- all existing recovery/resource mutations reject dead characters; and +- legacy exchange documents remain readable. + +### Web + +Verify: + +- M.D. weapons are selectable and accurately labelled; +- M.D.C. protection is targetable when ready; +- unrolled armor and dead targets explain why they are disabled; +- every tiered route format includes units, conversion, and before/after evidence; +- fatal state disables every relevant control without color-only meaning; +- old S.D.C. history formatting is unchanged; +- combat colors remain semantic with no non-magic cyan; +- async ownership rejects late results after route changes; and +- history disclosure/accessibility contracts remain intact. + +## Live-browser acceptance + +Use at least two local dossiers and server-owned dice to verify: + +1. M.D. against intact M.D.C. armor. +2. A final M.D.C. point absorbing an oversized blast with no body change. +3. A subsequent M.D. hit reaching the now-unprotected wearer. +4. S.D.C. below 100 producing an immutable no-effect route. +5. S.D.C. at/above 100 converting and ablating M.D.C. +6. Depleted-shell S.D.C. strike totals on both sides of 8. +7. M.D. against an unprotected character producing deterministic death. +8. The dead dossier remaining readable while damage, recovery, rest, and combat + controls remain unavailable. +9. Old S.D.C. history beside new tier-aware results. +10. A-to-B route changes with pending/completed exchanges and no stale UI state. +11. Keyboard and screen-reader labels, narrow-rail overflow, and clean consoles. + +## Validation gates + +Before publication, run with fresh, time-scoped evidence: + +```text +vp run @riftforge/rules#check +vp run @riftforge/rules#test +vp run @riftforge/backend#check +vp run @riftforge/backend#test +vp run @riftforge/web#check +vp run @riftforge/web#test +vp check +vp test +git diff --check +``` + +User-visible changes also require the live-browser acceptance above. Test counts +must be quoted as observations from the final branch revision, never as timeless +project totals. + +## Tracker and documentation delivery + +- Keep Issue #51 aligned with the implemented core scope and rendered evidence. +- Keep optional near-fatal survival in Issue #54. +- Keep the generic page-stamped effect pipeline in Issue #53. +- Update README/current-status wording once M.D.C. exchanges are live. +- Record explicit exclusions and final validation evidence in the PR and Issue + #51. +- Follow branch -> PR -> Cubic review -> human merge. Never merge the PR from the + agent workflow. + +## Success criteria + +Issue #51 is complete when: + +- printed M.D.C. constants are page-stamped and load-validated; +- one pure resolver covers the approved S.D.C./M.D.C. routing matrix; +- existing history remains valid without a rewrite; +- Convex persists server-authorized armor/body/death outcomes atomically and + exactly once; +- terminal death cannot be bypassed by existing mutations; +- the SolidJS rail presents all new route evidence and terminal state; +- package, root, diff, and live-browser gates pass; and +- the branch is published as a PR for Cubic and human review. From 301b0061de8b0a21608eca064d2f609835522419 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 17:56:59 +0800 Subject: [PATCH 02/25] docs: plan full mdc combat exchange --- .../plans/2026-07-21-mdc-combat-exchange.md | 482 ++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 .codex/superpowers/plans/2026-07-21-mdc-combat-exchange.md diff --git a/.codex/superpowers/plans/2026-07-21-mdc-combat-exchange.md b/.codex/superpowers/plans/2026-07-21-mdc-combat-exchange.md new file mode 100644 index 0000000..b8fddd4 --- /dev/null +++ b/.codex/superpowers/plans/2026-07-21-mdc-combat-exchange.md @@ -0,0 +1,482 @@ +# Full M.D.C. Combat Exchange Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver issue #51 by extending the persisted combat exchange through the complete S.D.C./M.D.C. routing matrix, including deterministic fatal overflow and a terminal dead-dossier state, while preserving legacy exchange history. + +**Architecture:** Extend the page-stamped rules boundary and existing pure combat resolver rather than creating a second M.D.C. path. Every newly resolved hit emits a version-2 evidence route containing native damage, conversion, protection/body deltas, final-blast absorption, and life-state outcome. Convex remains the sole dice and persistence authority and applies the route atomically. SolidJS exposes legal M.D. weapons, explicit units/history, and a readable terminal state within the existing Ley Terminal design. + +**Tech Stack:** TypeScript, Zod, JSON rules content, Convex 1.42, SolidJS 1.9, Tailwind CSS 4, Vite+ (`vp`), Vite+ Test, pnpm 11. + +## Global Constraints + +- Work directly in `D:\Projects\riftforge` on `feat/mdc-combat-exchange`; do not create a worktree. +- Treat `.codex/superpowers/specs/2026-07-21-mdc-combat-exchange-design.md` as the approved contract and rendered RUE pp. 287, 288, and 354-359 as rules authority. +- Keep optional pp. 358-359 injury/survival work in issue #54 and the generic effects pipeline in issue #53; neither belongs in #51. +- Use the existing persisted exchange, not a parallel M.D.C. resolver. Keep `characters.applyDamage` S.D.C.-only; hostile M.D. writes occur only through exchanges. +- Multiply critical damage in the native tier before conversion. `totalDamage` remains the native completed weapon total. +- Apply A.R. to S.D.C. armor for both tiers. M.D.C. armor has no A.R. The final M.D.C. absorbs the full destroying blast with no spill. +- A depleted M.D.C. shell stops S.D.C. strike totals 1-7, admits 8+, and does not stop M.D. +- Raw H.P. below `-P.E.` is fatal; exactly `-P.E.` is coma. Fatal persistence is S.D.C. `0`, H.P. at the floor, `current.lifeState = "dead"`. +- Rules remain pure/deterministic. Convex owns dice and state. Clients never choose damage tier, route, conversion, or after-values. +- Preserve exact legacy route validation/history without migration. Old/racing pending exchanges stale safely. +- Preserve route-epoch and exchange-ID ownership guards because parameterized routes do not remount. +- Use stable `ConvexError` codes for expected combat refusals; the web must not parse English messages. +- Use Vite+; in `packages/backend` use `pnpm exec`, never `npx`. +- Work red -> green -> refactor. Run package gates before root gates. Checkpoint-commit every task. +- No AI attribution. Never commit to `main`, merge, or manually close #51; the human maintainer merges. +- Live-browser acceptance is mandatory for this user-visible change. + +### Frontend direction + +- Continue the compact Ley Terminal command rail; add no rounded card system, decorative art, or cyan M.D.C. language. +- Use amber for stopped/armor absorption, blood red for body/fatal harm, and green for defended/safely settled results. +- Label every amount `S.D.C.` or `M.D.`; color is never the only signal. +- Keep a dead dossier, equipment, inventory management, narrative, cancellation cleanup, and history readable while disabling gameplay actions with an explicit reason. + +--- + +## File Map + +### Rules + +- `packages/rules/src/schema/combat-exchange.ts` and `src/content/combat/combat-exchange.json`: page-stamped M.D.C. constants and stable errors. +- `packages/rules/src/schema/character.ts`: optional persisted terminal marker only. +- `packages/rules/src/engine/combat.ts`: fatal-aware body damage plus legacy pool wrapper. +- `packages/rules/src/engine/character.ts`: derived `alive | coma | dead` and terminal invariants. +- `packages/rules/src/engine/combat-exchange.ts`: legal M.D. profiles, M.D.C. readiness, v2 tokens, complete routing matrix. +- `packages/rules/src/index.ts`: additive exports. +- `packages/rules/tests/{combat,character,combat-exchange}.test.ts`: pure boundaries and matrix. + +### Backend + +- `packages/backend/convex/schema.ts`: optional dead marker. +- `packages/backend/convex/character_state.ts`: shared living guard. +- `packages/backend/convex/characters.ts`: fatal manual S.D.C. damage and terminal guards. +- `packages/backend/convex/combat_values.ts`: exact union of legacy and v2 routes. +- `packages/backend/convex/combat.ts`: target readiness, legal M.D. declarations, atomic route writes. +- `packages/backend/convex/_generated/{api,dataModel}.d.ts`: regenerate only if codegen changes them. +- `packages/backend/tests/{characters,combat}.test.ts`: mutation, compatibility, race, atomicity tests. + +### Web and delivery + +- `apps/web/src/lib/combat-exchange.ts`: version-aware presentation and disabled reasons. +- `apps/web/src/components/combat-exchange-panel.tsx`: M.D. selection/results and terminal mode. +- `apps/web/src/components/sheet-view.tsx`: derived life-state presentation and gameplay disabling. +- `apps/web/src/pages/character-sheet.tsx`: terminal command rail and navigation ownership. +- `apps/web/tests/combat-exchange.test.ts`: selection, labels, routes, tones, ownership. +- Create `apps/web/tests/character-sheet.test.ts`: terminal dossier behavior. +- `README.md`, approved design, and this plan: final aligned evidence. + +--- + +### Task 1: Page-Stamped M.D.C. Rules Boundary + +**Files:** Modify `packages/rules/src/schema/combat-exchange.ts`, `packages/rules/src/content/combat/combat-exchange.json`, `packages/rules/src/index.ts`, and `packages/rules/tests/combat-exchange.test.ts`. + +**Contract:** + +```ts +pages: { + megaDamageIntro: 288; + megaDamageCombat: 355; +} +rules: { + sdcPerMd: 100; + minimumSdcToDamageMdc: 100; + depletedMdcArmorBypassStrike: 8; + finalMdcAbsorbsDestroyingBlast: true; +} +``` + +- [ ] Add failing tests asserting all six exact values and negative schema tests for altered literals. +- [ ] Run `vp test packages/rules/tests/combat-exchange.test.ts`; observe missing-field failure. +- [ ] Add `z.literal(...)` fields under the existing `pages`/`rules` objects and matching JSON values; retain all existing constants/exports. +- [ ] Run `vp test packages/rules/tests/combat-exchange.test.ts`, `vp run @riftforge/rules#check`, and `vp run @riftforge/rules#test`; expect PASS. +- [ ] Commit: + +```text +git add -- packages/rules/src/schema/combat-exchange.ts packages/rules/src/content/combat/combat-exchange.json packages/rules/src/index.ts packages/rules/tests/combat-exchange.test.ts +git commit -m "feat(rules): stamp mega-damage exchange rules" +``` + +--- + +### Task 2: Fatal-Aware Body Damage and Derived Life State + +**Files:** Modify `packages/rules/src/schema/character.ts`, `packages/rules/src/engine/{combat,character}.ts`, `packages/rules/src/index.ts`, and `packages/rules/tests/{combat,character}.test.ts`. + +**Contract:** + +```ts +export type LifeState = "alive" | "coma" | "dead"; +export interface BodyDamageResult { + before: VitalsPool; + after: VitalsPool; + rawHitPoints: number; + lifeState: LifeState; +} +export function applyBodyDamage( + pool: VitalsPool, + damage: number, + comaDeathFloor: number, +): BodyDamageResult; +``` + +Persist only `lifeState: z.literal("dead").optional()`; add `lifeState` to `CharacterSheet.vitals`. + +- [ ] Add a table for `{sdc:0, hitPoints:1}`, floor `-10`: damage `1 -> coma/0`, `11 -> coma/-10`, `12 -> dead/-10` with raw `-11`. Prove S.D.C. drains first and existing `applyDamage` still returns the clamped pool. +- [ ] Add character tests: positive H.P. => alive; H.P. `0` through floor => coma; dead marker valid only with rolled vitals, S.D.C. `0`, H.P. exactly floor; contradictory/unrolled dead state rejected; legacy marker-absent documents valid. +- [ ] Run `vp test packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts`; observe failure. +- [ ] Implement: + +```ts +const sdcDamage = Math.min(pool.sdc, damage); +const rawHitPoints = pool.hitPoints - (damage - sdcDamage); +const lifeState = rawHitPoints < comaDeathFloor ? "dead" : rawHitPoints <= 0 ? "coma" : "alive"; +const after = { + sdc: pool.sdc - sdcDamage, + hitPoints: Math.max(comaDeathFloor, rawHitPoints), +}; +``` + +Keep integer/nonnegative validation. Make `applyDamage(...)` return `applyBodyDamage(...).after` for compatibility. + +- [ ] In `deriveSheet`, validate terminal invariants after pools/floor are known and derive unpersisted alive/coma from current H.P. +- [ ] Run focused tests and rules check/test; expect PASS. +- [ ] Commit: + +```text +git add -- packages/rules/src/schema/character.ts packages/rules/src/engine/combat.ts packages/rules/src/engine/character.ts packages/rules/src/index.ts packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts +git commit -m "feat(rules): derive terminal life state" +``` + +--- + +### Task 3: Legal M.D. Profiles, M.D.C. Readiness, and v2 Tokens + +**Files:** Modify `packages/rules/src/schema/combat-exchange.ts`, `packages/rules/src/engine/combat-exchange.ts`, and `packages/rules/tests/combat-exchange.test.ts`. + +**Contract:** Supported `AttackProfile.damageType` is `"sdc" | "md"`. Preserve M.D.C. protection even at current `0`; worn unrolled armor derives `mdcArmor` with absent max/current. Add `combatantDead` and `armorNotReady` errors, but retain legacy unsupported error values for persisted data. + +- [ ] Replace M.D. refusal tests with legal energy-pistol/rifle profiles, printed formulas, unchanged strike/critical data, and `damageType: "md"`. +- [ ] Test full/partial/depleted M.D.C. armor stays `mdcArmor`; unrolled armor exposes absent pools; depleted S.D.C. armor retains current no-protection behavior. +- [ ] Test attacker token changes with damage tier/selected weapon. Defender token changes with life state, tier, M.D.C. readiness/max/current. Narrative, P.P.E., unrelated inventory remain excluded. +- [ ] Run focused rules test; observe current refusal/collapse failure. +- [ ] Return the validated item tier directly in supported profiles. Preserve all worn M.D.C. armor in `deriveProtection`. +- [ ] Prefix explicit ordered token tuples `attacker-v2`/`defender-v2`; include the new relevant fields without serializing the whole sheet. +- [ ] Run focused test and rules check/test; expect PASS. +- [ ] Commit: + +```text +git add -- packages/rules/src/schema/combat-exchange.ts packages/rules/src/engine/combat-exchange.ts packages/rules/tests/combat-exchange.test.ts +git commit -m "feat(rules): authorize mega-damage attacks" +``` + +--- + +### Task 4: Version-2 Tiered Routing and Resolution + +**Files:** Modify `packages/rules/src/engine/combat-exchange.ts`, `packages/rules/src/index.ts`, and `packages/rules/tests/combat-exchange.test.ts`. + +**Contract:** + +```ts +export type DamageAmount = { type: "sdc" | "md"; value: number }; +export type ProtectionDamageSnapshot = { + kind: "sdcArmor" | "mdcArmor"; + itemId: string; + name: string; + before: number; + after: number; +}; +export type BodyDamageSnapshot = { before: VitalsPool; after: VitalsPool }; +``` + +`TieredDamageRoute` is an exact union: + +- `{routingVersion:2, kind:"stopped", reason:"intactMdcImpervious"|"depletedMdcShell", nativeDamage, armor, body}` +- `{routingVersion:2, kind:"armor", nativeDamage, convertedDamage?, armor, body, finalBlastAbsorbed}` +- `{routingVersion:2, kind:"body", nativeDamage, convertedDamage?, armor?, body, lifeState:{before:"alive"|"coma", after:"alive"|"coma"}}` +- `{routingVersion:2, kind:"fatal", nativeDamage, convertedDamage?, armor?, body, lifeState:{before:"alive"|"coma", after:"dead"}}` + +All new hits use this union. Keep legacy `SdcDamageRoute` exported only for persisted validation/presentation. + +- [ ] Add intact-M.D.C. conversion tests for `99/100/199/200` plus printed `450/496 -> 4 M.D.C.`. +- [ ] Add final-point M.D.C. no-spill and `finalBlastAbsorbed` tests. +- [ ] Add depleted-shell tests for S.D.C. strike `7/8` and native M.D. bypass. +- [ ] Add S.D.C.-armor tests for S.D. and M.D. attacks at/below/above A.R. +- [ ] Add no-armor S.D./M.D. body tests for S.D.C.-before-H.P., exact floor, and fatal overflow. +- [ ] Add a critical M.D. resolver test proving `totalDamage = native roll * multiplier` before `convertedDamage = totalDamage * 100`. +- [ ] Assert every new hit has v2, native/converted evidence, exact armor/body snapshots, and no unsupported-M.D.C. route. +- [ ] Run focused test; observe S.D.C.-only failures. +- [ ] Implement exact helpers: + +```ts +const sdcToMd = (value: number): DamageAmount => ({ + type: "md", + value: Math.floor(value / combatExchangeRules.rules.sdcPerMd), +}); +const mdToSdc = (value: number): DamageAmount => ({ + type: "sdc", + value: value * combatExchangeRules.rules.sdcPerMd, +}); +``` + +- [ ] Implement routing in this order: S.D.C. armor A.R.; intact M.D.C.; depleted M.D.C. shell; no protection; fatal-aware body. `damageArmor` clamps armor; `finalBlastAbsorbed` is true when the armor hit ends at `0`; never spill armor damage. +- [ ] Resolve using `{type: input.attack.damageType, value: totalDamage}` after critical multiplication and pass the actual tier to strike evaluation. +- [ ] Run focused tests and rules check/test; expect PASS. +- [ ] Commit: + +```text +git add -- packages/rules/src/engine/combat-exchange.ts packages/rules/src/index.ts packages/rules/tests/combat-exchange.test.ts +git commit -m "feat(rules): resolve tiered combat damage" +``` + +--- + +### Task 5: Backward-Compatible Convex Values and Schema + +**Files:** Modify `packages/backend/convex/schema.ts`, `packages/backend/convex/combat_values.ts`, generated types if changed, and `packages/backend/tests/combat.test.ts`. + +**Contract:** Keep the legacy route validator exact, add separately discriminated v2 validators matching Task 4, and use `route: v.union(legacySdcDamageRouteValidator, tieredDamageRouteValidator)`. Add `lifeState: v.optional(v.literal("dead"))` to character current state. + +- [ ] Add fixtures proving legacy armor/body routes and v2 stopped/armor/fatal routes all insert/read unchanged. Add negative fixtures for missing v2 native damage, fatal ending in coma, and extra legacy fields. +- [ ] Run `vp test packages/backend/tests/combat.test.ts`; observe validator failures. +- [ ] Add exact `DamageAmount`, protection/body snapshot, and four v2 route validators with `v.literal(2)`. Optional fields are optional only on branches allowed by the rules type. +- [ ] Preserve legacy unsupported error codes; add `combatantDead` and `armorNotReady`. +- [ ] Add the dead marker to schema. From `packages/backend`, run `pnpm exec convex codegen`; do not hand-edit or commit generated files unless they change. +- [ ] Run backend focused test and backend check/test; expect PASS. +- [ ] Commit: + +```text +git add -- packages/backend/convex/schema.ts packages/backend/convex/combat_values.ts packages/backend/convex/_generated/dataModel.d.ts packages/backend/convex/_generated/api.d.ts packages/backend/tests/combat.test.ts +git commit -m "feat(backend): validate tiered combat routes" +``` + +--- + +### Task 6: Terminal Character Writes and Fatal Manual S.D.C. Damage + +**Files:** Modify `packages/backend/convex/character_state.ts`, `packages/backend/convex/characters.ts`, and `packages/backend/tests/characters.test.ts`. + +**Contract:** + +```ts +export function requireLiving(character: Character, action: string): void; +// Throws: Life signs terminated — dead characters cannot ${action}. +``` + +Manual damage returns `lifeState: "alive" | "coma" | "dead"` but accepts no tier argument. + +- [ ] Add manual damage tests at P.E. 10/current `{sdc:0,hp:1}`: 11 damage stores coma at -10 without marker; 12 stores dead at -10 with marker and raw overflow is not persisted. +- [ ] Add dead-character rejections without state change for full update, roll/restore vitals, damage/healing, rest/meditation, treatment, ley draw, and casting (including cross-character healing). +- [ ] Prove narrative edits and inventory add/remove/equip remain available and preserve the marker. +- [ ] Run `vp test packages/backend/tests/characters.test.ts`; observe current floor-only/unguarded failures. +- [ ] Implement `requireLiving` and call it after authoritative load but before dice or gameplay writes. Guard both caster and healing target. Guard existing document before full replacement. +- [ ] Replace pool-only damage with: + +```ts +const result = applyBodyDamage(damagePools(sheet), args.amount, sheet.vitals.comaDeathFloor); +const current = { + ...character.current, + sdc: result.after.sdc, + hitPoints: result.after.hitPoints, + ...(result.lifeState === "dead" ? { lifeState: "dead" as const } : {}), +}; +``` + +Patch once; return before/after/amount/lifeState. Keep the mutation S.D.C.-only. + +- [ ] Run focused test and backend check/test; expect PASS. +- [ ] Commit: + +```text +git add -- packages/backend/convex/character_state.ts packages/backend/convex/characters.ts packages/backend/tests/characters.test.ts +git commit -m "feat(backend): enforce terminal life state" +``` + +--- + +### Task 7: M.D.C.-Ready Target Discovery and Declaration + +**Files:** Modify `packages/backend/convex/combat.ts` and `packages/backend/tests/combat.test.ts`. + +**Contract:** Target summaries add `lifeState`, full `ProtectionState`, and optional `disabledReason: "defenderNotReady" | "armorNotReady" | "combatantDead"`. + +- [ ] Test discovery for intact/depleted rolled M.D.C. armor (enabled), unrolled worn M.D.C. (`armorNotReady`), dead target (`combatantDead` precedence), unrolled body (`defenderNotReady`), and unchanged S.D.C. target. +- [ ] Test legal energy-pistol/rifle declarations store `damageType:"md"` and roll strike exactly once. Dead attacker/defender and unready armor must insert nothing and roll nothing. +- [ ] Run backend combat test; observe current unsupported refusals. +- [ ] Reject dead sheets with `combatFailure("combatantDead", ...)`. Reject only M.D.C. protection with absent max/current using `combatFailure("armorNotReady", ...)`; do not reject legal M.D. or depleted armor. +- [ ] Persist the real attack profile/tier. Re-read both sheets, verify expected item identity/state, compute v2 tokens, roll once, and insert once. Keep current indexes/limits. +- [ ] Run focused test and backend check/test; expect PASS. +- [ ] Commit: + +```text +git add -- packages/backend/convex/combat.ts packages/backend/tests/combat.test.ts +git commit -m "feat(backend): declare mega-damage attacks" +``` + +--- + +### Task 8: Atomic Tiered Resolution, Death, and Stale Races + +**Files:** Modify `packages/backend/convex/combat.ts` and `packages/backend/tests/combat.test.ts`. + +- [ ] Add atomic intact-M.D.C. tests: S.D.C. 99 stopped, 100 ablates 1, and a destroying M.D. blast patches armor only. +- [ ] Add atomic depleted-shell tests: S.D.C. strike 7 stopped, strike 8 hits body, and M.D. converts into body. +- [ ] Add atomic S.D.C.-armor tests for M.D. at/below and above A.R. +- [ ] Add exact-floor coma and one-point-overflow fatal marker tests. Assert native `totalDamage` and full route evidence. +- [ ] Add stale races for death, armor readiness, and selected-weapon change after declaration. +- [ ] Add idempotency/compatibility tests: cleanup cancellation remains legal, duplicate response never double-applies, and legacy v1 tokens stale safely. +- [ ] Run backend combat test; observe S.D.C.-only write failures. +- [ ] Call `resolveCombatExchange` with stored attack tier, completed server rolls, rederived protection/body, and derived floor. Convex does no conversion. +- [ ] Apply one route patch: stopped => none; armor => armor only; body => S.D.C./H.P.; fatal => S.D.C./H.P./dead together. Patch exchange resolution in the same Convex mutation. Legacy routes are never newly generated. +- [ ] Check v2 tokens before defense/damage rolls. Any race, including newly dead, settles through stale logic with no character patch; stable dead errors are for new declarations. +- [ ] Run `vp test packages/backend/tests/combat.test.ts packages/backend/tests/characters.test.ts` and backend check/test; expect PASS. +- [ ] Commit: + +```text +git add -- packages/backend/convex/combat.ts packages/backend/tests/combat.test.ts +git commit -m "feat(backend): persist tiered combat outcomes" +``` + +--- + +### Task 9: Unit-Aware Web Presentation and Legal M.D. Selection + +**Files:** Modify `apps/web/src/lib/combat-exchange.ts`, `apps/web/src/components/combat-exchange-panel.tsx`, and `apps/web/tests/combat-exchange.test.ts`. + +- [ ] Replace M.D. disabled tests: legal catalog M.D. weapons are enabled and labeled with formula plus `M.D.`; invalid modes retain their reason. +- [ ] Pin server-reason copy exactly: `defenderNotReady -> Roll this target's H.P. and S.D.C. first.`, `armorNotReady -> Roll this target's worn armor M.D.C. first.`, `combatantDead -> Life signs terminated; this target cannot enter combat.` +- [ ] Pin exact legacy formatting unchanged and v2 summaries for stopped S.D.C., `496 S.D.C. -> 4 M.D.C.`, native M.D. ablation, M.D.-to-body conversion, final blast, depleted shell, and fatal termination. +- [ ] Pin tones: stopped/armor `warn`, body/fatal `bad`, defended `good`, cancelled `dim`. +- [ ] Run `vp test apps/web/tests/combat-exchange.test.ts`; observe M.D. refusal/S.D.C.-only formatter failures. +- [ ] Remove unsupported-M.D. copy, map only server stable disabled reasons, and add: + +```ts +function isTieredRoute(route: ExchangeRoute): route is TieredDamageRoute { + return "routingVersion" in route && route.routingVersion === 2; +} +``` + +Keep legacy formatter branch. Build v2 text from persisted native/converted amounts, reason, before/after values, final-blast flag, and life state; never reconstruct evidence from `totalDamage`. + +- [ ] Update panel result labels using existing primitives and rail structure; add no new card system. +- [ ] Run focused test and web check/test; expect PASS. +- [ ] Commit: + +```text +git add -- apps/web/src/lib/combat-exchange.ts apps/web/src/components/combat-exchange-panel.tsx apps/web/tests/combat-exchange.test.ts +git commit -m "feat(web): present tiered combat outcomes" +``` + +--- + +### Task 10: Terminal Dossier UI Without Hiding History + +**Files:** Modify `apps/web/src/components/sheet-view.tsx`, `apps/web/src/components/combat-exchange-panel.tsx`, `apps/web/src/pages/character-sheet.tsx`, `apps/web/tests/combat-exchange.test.ts`; create `apps/web/tests/character-sheet.test.ts`. + +**Contract:** Add `gameplayDisabledReason?: string` to `SheetView` props. Use `Life signs terminated; gameplay actions are unavailable.` consistently. + +- [ ] Add a dead `SheetView` test: terminal label/reason render; identity/vitals/equipment remain; save/skill/weapon/spell/combat rows are disabled with `aria-disabled`/`title`; inventory remains usable. +- [ ] Add a dead page/rail test: narrative, telemetry, history, navigation, and cancellation remain; declaration/response/damage/restore/recovery controls are unavailable. +- [ ] Add an alive-sheet/page regression proving existing controls remain wired. +- [ ] Add route-change regression: dead A -> living B during in-flight request resets drafts, increments epoch, ignores A result, enables B actions. +- [ ] Run focused web tests; observe missing terminal behavior. +- [ ] Pass terminal reason into save/skill/combat/weapon/spell rows while retaining the actions object for inventory. Terminal reason takes precedence over spell affordability. +- [ ] In the page, derive `sheet()?.vitals.lifeState === "dead"`. Replace command-rail gameplay controls with a danger terminal alert; keep telemetry. In combat panel, keep outgoing cancellation/recent history but suppress declaration/response controls and show the alert. +- [ ] Extend the existing ID-change reset/epoch logic; do not key/remount the page or replace ownership tokens with booleans. +- [ ] Run focused tests and web check/test; expect PASS. +- [ ] Commit: + +```text +git add -- apps/web/src/components/sheet-view.tsx apps/web/src/components/combat-exchange-panel.tsx apps/web/src/pages/character-sheet.tsx apps/web/tests/character-sheet.test.ts apps/web/tests/combat-exchange.test.ts +git commit -m "feat(web): render terminal character dossiers" +``` + +--- + +### Task 11: Cross-Package Hardening and Automated Verification + +**Files:** Modify only files already in scope when a reproduced failure requires it; update this plan's checked steps/evidence. + +- [ ] Run focused boundary suite: + +```text +vp test packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts packages/rules/tests/combat-exchange.test.ts packages/backend/tests/characters.test.ts packages/backend/tests/combat.test.ts apps/web/tests/combat-exchange.test.ts apps/web/tests/character-sheet.test.ts apps/web/tests/convex.test.ts +``` + +- [ ] Run all package gates: + +```text +vp run @riftforge/rules#check +vp run @riftforge/rules#test +vp run @riftforge/backend#check +vp run @riftforge/backend#test +vp run @riftforge/web#check +vp run @riftforge/web#test +``` + +- [ ] Run `vp check`, `vp test`, and `git diff --check`; expect PASS. +- [ ] Audit the bug class: + +```text +rg -n "unsupportedMdWeapon|unsupportedMdcProtection|Full M\.D\.C\. combat is follow-up work|damageType: \"sdc\"" packages apps README.md +rg -n "applyDamage\(|applyBodyDamage\(|lifeState|routingVersion" packages apps +rg -n "TODO|TBD|FIXME" packages/rules/src packages/backend/convex apps/web/src .codex/superpowers/plans/2026-07-21-mdc-combat-exchange.md +``` + +Legacy validator/enum hits are expected; active refusals, hardcoded S.D.C. resolution, unguarded writes, and incomplete implementation markers are not. + +- [ ] Compare rules type, Convex validator, and formatter branch-by-branch. Confirm client non-authority, one atomic character patch, exact-floor distinction, and no final-blast spill. +- [ ] Use `superpowers:requesting-code-review`. Reproduce findings, add regression tests for real bugs, fix root causes, rerun affected gates. +- [ ] If hardening changes code, commit `fix(combat): harden mega-damage invariants`; do not create an empty commit. + +--- + +### Task 12: Live Convex and Browser Acceptance + +**Files:** None expected; for a live defect modify the narrow source/test responsible. + +- [ ] Confirm port 3210 has no stale owner. Start `pnpm exec convex dev` in `packages/backend` and `vp dev` in `apps/web`. +- [ ] Seed two reproducible dossiers with `pnpm exec convex run characters:create`: an alive attacker with S.D./M.D. weapons and an alive defender with worn rolled M.D.C. armor. Record returned IDs. +- [ ] At `http://localhost:5173`, verify legal M.D. selection; sub-100 stop; 100+ floor conversion; native ablation; final-blast no spill; depleted-shell 7/8; M.D. through depleted shell; M.D. vs S.D.C. armor A.R.; fatal terminal marker; legacy and v2 history together. +- [ ] On the dead dossier, verify explicit inaccessible gameplay actions while identity, pools, equipment, inventory, narrative, cleanup cancellation, and history remain. Navigate to the living ID without reload; drafts reset/actions re-enable. +- [ ] Check desktop and narrow viewport, keyboard/accessibility text, layout clipping, and console errors/warnings. +- [ ] For defects: add failing automated test, fix, rerun package gates, repeat scenario. +- [ ] Stop web, Convex CLI, and orphaned backend; confirm ports 5173/3210 are closed. + +--- + +### Task 13: Documentation, Issue Evidence, and Draft PR + +**Files:** Modify `README.md`, approved design spec, and this plan. + +- [ ] Replace README's stale “full M.D.C. interaction remains future work” boundary with completed conversion/armor/shell/final-blast/death behavior; defer optional survival to #54. +- [ ] Append exact date/time, package/root command outputs and fresh counts, live IDs/scenarios, viewports, and console result to the design; check all completed plan boxes. Never reuse older counts. +- [ ] Re-run `vp check`, `vp test`, `git diff --check`, and `git status --short --branch`. +- [ ] Commit: + +```text +git add -- README.md .codex/superpowers/specs/2026-07-21-mdc-combat-exchange-design.md .codex/superpowers/plans/2026-07-21-mdc-combat-exchange.md +git commit -m "docs: record mega-damage combat delivery" +``` + +- [ ] Push `feat/mdc-combat-exchange` and open a draft PR to `main` summarizing architecture, compatibility, fatal boundary, automation, and live evidence. Link `Closes #51`, `Follow-up #54`, and `Roadmap #53`; no AI attribution; never merge. +- [ ] Comment on #51 with verified time-scoped evidence/PR link. Do not close it manually. +- [ ] Wait for Cubic. Reproduce every finding; fix real root causes with tests or explain invalid findings with evidence; rerun affected gates after each push. Stop ready for human merge. + +--- + +## Final Acceptance Checklist + +- [ ] Rendered/page-stamped authority covers every mechanic. +- [ ] `99/100/199/200`, printed `450/496`, critical-before-conversion, final-blast, depleted-shell `7/8`, and M.D.-vs-S.D.C.-A.R. are pinned. +- [ ] Exact `-P.E.` is coma; below is dead; all gameplay mutations reject dead while inventory/narrative preserve the marker. +- [ ] Legacy routes read without migration; pending legacy/racing exchanges stale and never double-apply. +- [ ] New history exposes native tier, conversion, reason, and before/after evidence. +- [ ] Dead dossiers remain readable/accessibly terminal; route navigation owns async results. +- [ ] Rules/backend/web package gates and root gates pass with fresh evidence. +- [ ] Live desktop/narrow acceptance passes with clean console. +- [ ] README, #51, #54, #53, design, plan, and draft PR agree; human retains merge authority. From 3e35537e54c49916b355ade96a20f13df3146f45 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 18:13:25 +0800 Subject: [PATCH 03/25] feat(rules): stamp mega-damage exchange rules --- .../src/content/combat/combat-exchange.json | 8 ++ packages/rules/src/schema/combat-exchange.ts | 8 ++ packages/rules/tests/combat-exchange.test.ts | 84 ++++++++++++++++--- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/rules/src/content/combat/combat-exchange.json b/packages/rules/src/content/combat/combat-exchange.json index 56c7206..5da18b5 100644 --- a/packages/rules/src/content/combat/combat-exchange.json +++ b/packages/rules/src/content/combat/combat-exchange.json @@ -2,13 +2,21 @@ "book": "Rifts Ultimate Edition", "pages": { "armorAndVitals": 287, + "megaDamageIntro": 288, "sdcCombat": 339, "defense": 340, "damage": 341, "automaticDodge": 344, + "megaDamageCombat": 355, "modernWeapons": 360, "rangedDodging": 361 }, + "rules": { + "sdcPerMd": 100, + "minimumSdcToDamageMdc": 100, + "depletedMdcArmorBypassStrike": 8, + "finalMdcAbsorbsDestroyingBlast": true + }, "minimumStrikeTotal": { "melee": 5, "ranged": 8 }, "rangedDodgeModifier": { "pointBlank": -10, "close": -5, "normal": 0 } } diff --git a/packages/rules/src/schema/combat-exchange.ts b/packages/rules/src/schema/combat-exchange.ts index cc564fb..b0518ee 100644 --- a/packages/rules/src/schema/combat-exchange.ts +++ b/packages/rules/src/schema/combat-exchange.ts @@ -88,13 +88,21 @@ export const combatExchangeRulesSchema = z.object({ book: z.string().min(1), pages: z.object({ armorAndVitals: z.literal(287), + megaDamageIntro: z.literal(288), sdcCombat: z.literal(339), defense: z.literal(340), damage: z.literal(341), automaticDodge: z.literal(344), + megaDamageCombat: z.literal(355), modernWeapons: z.literal(360), rangedDodging: z.literal(361), }), + rules: z.object({ + sdcPerMd: z.literal(100), + minimumSdcToDamageMdc: z.literal(100), + depletedMdcArmorBypassStrike: z.literal(8), + finalMdcAbsorbsDestroyingBlast: z.literal(true), + }), minimumStrikeTotal: z.object({ melee: z.literal(5), ranged: z.literal(8) }), rangedDodgeModifier: z.object({ pointBlank: z.literal(-10), diff --git a/packages/rules/tests/combat-exchange.test.ts b/packages/rules/tests/combat-exchange.test.ts index fc33458..62b70ea 100644 --- a/packages/rules/tests/combat-exchange.test.ts +++ b/packages/rules/tests/combat-exchange.test.ts @@ -4,6 +4,7 @@ import { armorSchema, authorizeCombatResponse, combatExchangeRules, + combatExchangeRulesSchema, defenderCombatStateToken, deriveAttackProfile, deriveDefenseOptions, @@ -78,22 +79,79 @@ function d20(die: number, bonus = 0, overrides: Partial = {}): D20Roll }; } +const expectedCombatExchangeRules = { + book: "Rifts Ultimate Edition", + pages: { + armorAndVitals: 287, + megaDamageIntro: 288, + sdcCombat: 339, + defense: 340, + damage: 341, + automaticDodge: 344, + megaDamageCombat: 355, + modernWeapons: 360, + rangedDodging: 361, + }, + rules: { + sdcPerMd: 100, + minimumSdcToDamageMdc: 100, + depletedMdcArmorBypassStrike: 8, + finalMdcAbsorbsDestroyingBlast: true, + }, + minimumStrikeTotal: { melee: 5, ranged: 8 }, + rangedDodgeModifier: { pointBlank: -10, close: -5, normal: 0 }, +} as const; + describe("combat exchange constants", () => { test("loads rendered-page S.D.C. combat values", () => { - expect(combatExchangeRules).toEqual({ - book: "Rifts Ultimate Edition", - pages: { - armorAndVitals: 287, - sdcCombat: 339, - defense: 340, - damage: 341, - automaticDodge: 344, - modernWeapons: 360, - rangedDodging: 361, + expect(combatExchangeRules).toEqual(expectedCombatExchangeRules); + }); + + test.each([ + [ + "mega-damage introduction page", + { + ...expectedCombatExchangeRules, + pages: { ...expectedCombatExchangeRules.pages, megaDamageIntro: 289 }, }, - minimumStrikeTotal: { melee: 5, ranged: 8 }, - rangedDodgeModifier: { pointBlank: -10, close: -5, normal: 0 }, - }); + ], + [ + "mega-damage combat page", + { + ...expectedCombatExchangeRules, + pages: { ...expectedCombatExchangeRules.pages, megaDamageCombat: 356 }, + }, + ], + [ + "S.D.C. per M.D. ratio", + { + ...expectedCombatExchangeRules, + rules: { ...expectedCombatExchangeRules.rules, sdcPerMd: 101 }, + }, + ], + [ + "minimum S.D.C. needed to damage M.D.C.", + { + ...expectedCombatExchangeRules, + rules: { ...expectedCombatExchangeRules.rules, minimumSdcToDamageMdc: 101 }, + }, + ], + [ + "depleted M.D.C. armor bypass strike", + { + ...expectedCombatExchangeRules, + rules: { ...expectedCombatExchangeRules.rules, depletedMdcArmorBypassStrike: 9 }, + }, + ], + [ + "final M.D.C. absorption rule", + { + ...expectedCombatExchangeRules, + rules: { ...expectedCombatExchangeRules.rules, finalMdcAbsorbsDestroyingBlast: false }, + }, + ], + ])("rejects an altered %s literal", (_name, candidate) => { + expect(combatExchangeRulesSchema.safeParse(candidate).success).toBe(false); }); }); From fa4195ad785f0b88c31f54e2a7d2caa478b3d1f5 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 18:31:17 +0800 Subject: [PATCH 04/25] feat(rules): derive terminal life state --- packages/rules/src/engine/character.ts | 34 +++++++++++--- packages/rules/src/engine/combat.ts | 30 ++++++++++-- packages/rules/src/schema/character.ts | 3 ++ packages/rules/tests/character.test.ts | 63 ++++++++++++++++++++++++++ packages/rules/tests/combat.test.ts | 34 ++++++++++++++ 5 files changed, 154 insertions(+), 10 deletions(-) diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index a2c8e46..403ac7d 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -16,6 +16,7 @@ import { psionicsSaveTarget, savingThrowTarget, type CombatProfile, + type LifeState, type StatRange, } from "./combat.ts"; import { getOcc, ppeRange } from "./occ.ts"; @@ -71,6 +72,7 @@ export interface CharacterSheet { hitPoints: StatValue; sdc: StatValue; comaDeathFloor: number; + lifeState: LifeState; /** Days of battle-injury treatment already applied this course. */ treatmentDays: number; }; @@ -159,6 +161,29 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { } } + const hitPoints = withRolled( + hitPointsRange(attrs.PE, level), + character.rolled?.hitPoints, + character.current?.hitPoints, + ); + const sdc = withRolled(physicalSdcRange(), character.rolled?.sdc, character.current?.sdc); + + let lifeState: LifeState; + if (character.lifeState === "dead") { + if (hitPoints.rolled === undefined || sdc.rolled === undefined) { + throw new Error("lifeState dead requires rolled vitals for H.P. and S.D.C."); + } + if (sdc.current !== 0) { + throw new Error("lifeState dead requires current S.D.C. to be 0."); + } + if (hitPoints.current !== floor) { + throw new Error(`lifeState dead requires current H.P. at the coma/death floor (${floor}).`); + } + lifeState = "dead"; + } else { + lifeState = hitPoints.current !== undefined && hitPoints.current <= 0 ? "coma" : "alive"; + } + const saves: Record = { magic: { targetRange: savingThrowTarget("magic")?.targetRange, @@ -282,13 +307,10 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { attributeBonuses, combat: sheetCombat, vitals: { - hitPoints: withRolled( - hitPointsRange(attrs.PE, level), - character.rolled?.hitPoints, - character.current?.hitPoints, - ), - sdc: withRolled(physicalSdcRange(), character.rolled?.sdc, character.current?.sdc), + hitPoints, + sdc, comaDeathFloor: floor, + lifeState, treatmentDays: character.current?.treatmentDays ?? 0, }, ppe: occ.ppe diff --git a/packages/rules/src/engine/combat.ts b/packages/rules/src/engine/combat.ts index cb7cd0f..5fce423 100644 --- a/packages/rules/src/engine/combat.ts +++ b/packages/rules/src/engine/combat.ts @@ -79,6 +79,15 @@ export interface VitalsPool { hitPoints: number; } +export type LifeState = "alive" | "coma" | "dead"; + +export interface BodyDamageResult { + before: VitalsPool; + after: VitalsPool; + rawHitPoints: number; + lifeState: LifeState; +} + /** * Deal damage to the pools: S.D.C. absorbs first — "all the S.D.C. of a living * thing must be reduced to zero before the Hit Points can be affected by @@ -86,13 +95,26 @@ export interface VitalsPool { * stop at the coma/death floor (0 down to the floor is the coma band; below * it, dead — RUE p.287, floor = `comaDeathFloor(pe)`). */ -export function applyDamage(pool: VitalsPool, damage: number, floor: number): VitalsPool { +export function applyBodyDamage( + pool: VitalsPool, + damage: number, + comaDeathFloor: number, +): BodyDamageResult { if (!Number.isInteger(damage) || damage < 0) { throw new Error(`Damage must be a non-negative integer, got ${damage}.`); } - const sdcAfter = Math.max(0, pool.sdc - damage); - const overflow = Math.max(0, damage - pool.sdc); - return { sdc: sdcAfter, hitPoints: Math.max(floor, pool.hitPoints - overflow) }; + const sdcDamage = Math.min(pool.sdc, damage); + const rawHitPoints = pool.hitPoints - (damage - sdcDamage); + const lifeState = rawHitPoints < comaDeathFloor ? "dead" : rawHitPoints <= 0 ? "coma" : "alive"; + const after = { + sdc: pool.sdc - sdcDamage, + hitPoints: Math.max(comaDeathFloor, rawHitPoints), + }; + return { before: pool, after, rawHitPoints, lifeState }; +} + +export function applyDamage(pool: VitalsPool, damage: number, floor: number): VitalsPool { + return applyBodyDamage(pool, damage, floor).after; } /** Total attacks per melee for a Hand-to-Hand type at a given level. */ diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts index 6721cd5..ca270d5 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -148,6 +148,9 @@ export const characterSchema = z.object({ treatmentDays: z.number().int().nonnegative().optional(), }) .optional(), + /** Persisted only for the terminal state. Alive/coma are derived from live + * H.P.; `deriveSheet` validates that dead agrees with the body pools. */ + lifeState: z.literal("dead").optional(), /** Optional player-authored identity; passed through to the sheet untouched. */ narrative: narrativeSchema.optional(), }); diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index 2696083..a887ee5 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -92,6 +92,69 @@ describe("deriveSheet — a level-1 Ley Line Walker", () => { }); describe("deriveSheet — edge cases", () => { + test("derives alive from positive H.P. and coma from zero through the floor", () => { + const rolled = { hitPoints: 18, sdc: 20 }; + + expect( + deriveSheet({ + ...leyLineWalker, + rolled, + current: { hitPoints: 1, sdc: 0 }, + }).vitals.lifeState, + ).toBe("alive"); + + for (const hitPoints of [0, -7, -14]) { + expect( + deriveSheet({ + ...leyLineWalker, + rolled, + current: { hitPoints, sdc: 0 }, + }).vitals.lifeState, + ).toBe("coma"); + } + }); + + test("accepts a dead marker only at the terminal rolled-vitals state", () => { + const sheet = deriveSheet({ + ...leyLineWalker, + rolled: { hitPoints: 18, sdc: 20 }, + current: { hitPoints: -14, sdc: 0 }, + lifeState: "dead", + }); + + expect(sheet.vitals.lifeState).toBe("dead"); + }); + + test("rejects unrolled or contradictory dead markers", () => { + expect(() => deriveSheet({ ...leyLineWalker, lifeState: "dead" })).toThrow(/rolled vitals/i); + expect(() => + deriveSheet({ + ...leyLineWalker, + rolled: { hitPoints: 18, sdc: 20 }, + current: { hitPoints: -14, sdc: 1 }, + lifeState: "dead", + }), + ).toThrow(/S\.D\.C\./); + expect(() => + deriveSheet({ + ...leyLineWalker, + rolled: { hitPoints: 18, sdc: 20 }, + current: { hitPoints: 0, sdc: 0 }, + lifeState: "dead", + }), + ).toThrow(/coma\/death floor/i); + }); + + test("legacy marker-absent documents remain valid", () => { + const sheet = deriveSheet({ + ...leyLineWalker, + rolled: { hitPoints: 18, sdc: 20 }, + current: { hitPoints: -14, sdc: 0 }, + }); + + expect(sheet.vitals.lifeState).toBe("coma"); + }); + test("a recorded H.P. roll shows as `rolled`, with `current` defaulting to it", () => { const sheet = deriveSheet({ ...leyLineWalker, rolled: { hitPoints: 18 } }); expect(sheet.vitals.hitPoints.rolled).toBe(18); diff --git a/packages/rules/tests/combat.test.ts b/packages/rules/tests/combat.test.ts index fa9242c..a58bbf6 100644 --- a/packages/rules/tests/combat.test.ts +++ b/packages/rules/tests/combat.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vite-plus/test"; import { + applyBodyDamage, applyDamage, attacksPerMelee, combatProfile, @@ -70,6 +71,39 @@ describe("applyDamage — S.D.C. before H.P. (RUE p.347), coma floor (p.287)", ( }); }); +describe("applyBodyDamage — fatal-aware body damage", () => { + const pool = { sdc: 0, hitPoints: 1 }; + const floor = -10; + + test.each([ + { damage: 1, hitPoints: 0, rawHitPoints: 0, lifeState: "coma" as const }, + { damage: 11, hitPoints: -10, rawHitPoints: -10, lifeState: "coma" as const }, + { damage: 12, hitPoints: -10, rawHitPoints: -11, lifeState: "dead" as const }, + ])( + "damage $damage derives $lifeState and clamps H.P. to $hitPoints", + ({ damage, hitPoints, rawHitPoints, lifeState }) => { + expect(applyBodyDamage(pool, damage, floor)).toEqual({ + before: pool, + after: { sdc: 0, hitPoints }, + rawHitPoints, + lifeState, + }); + }, + ); + + test("S.D.C. drains before H.P.", () => { + expect(applyBodyDamage({ sdc: 5, hitPoints: 1 }, 5, floor)).toMatchObject({ + after: { sdc: 0, hitPoints: 1 }, + rawHitPoints: 1, + lifeState: "alive", + }); + }); + + test("applyDamage remains a clamped-pool compatibility wrapper", () => { + expect(applyDamage(pool, 12, floor)).toEqual({ sdc: 0, hitPoints: -10 }); + }); +}); + describe("Hand to Hand progression (RUE pp.347-349)", () => { test("Basic attacks per melee: 4, +1 at 4/9/15", () => { expect(attacksPerMelee("basic", 1)).toBe(4); From d65299e3a27de7766576c4790206fbd2c03e3316 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 18:47:24 +0800 Subject: [PATCH 05/25] feat(rules): authorize mega-damage attacks --- packages/rules/src/engine/combat-exchange.ts | 13 +- packages/rules/src/schema/combat-exchange.ts | 2 + packages/rules/tests/combat-exchange.test.ts | 122 ++++++++++++++++--- 3 files changed, 115 insertions(+), 22 deletions(-) diff --git a/packages/rules/src/engine/combat-exchange.ts b/packages/rules/src/engine/combat-exchange.ts index 7557643..147c2e6 100644 --- a/packages/rules/src/engine/combat-exchange.ts +++ b/packages/rules/src/engine/combat-exchange.ts @@ -124,7 +124,7 @@ export function attackerCombatStateToken( export function defenderCombatStateToken(sheet: CharacterSheet): string { return JSON.stringify([ - "defender-v1", + "defender-v2", sheet.level, orderedAttributes(sheet), [ @@ -143,12 +143,14 @@ export function defenderCombatStateToken(sheet: CharacterSheet): string { sheet.vitals.hitPoints.rolled ?? null, sheet.vitals.hitPoints.current ?? null, sheet.vitals.comaDeathFloor, + sheet.vitals.lifeState, ], sheet.armor === undefined ? null : [ sheet.armor.item.id, sheet.armor.item.mdc === undefined ? "sdc" : "mdc", + sheet.armor.max !== undefined && sheet.armor.current !== undefined, sheet.armor.item.ar ?? null, sheet.armor.max ?? null, sheet.armor.current ?? null, @@ -185,7 +187,7 @@ export type AttackProfile = damageFormula: string; damageBonus: number; criticalOn: number; - damageType: "sdc"; + damageType: "sdc" | "md"; weapon: WeaponInstanceSnapshot & { name: string; category: WeaponCategory }; }; @@ -298,10 +300,6 @@ export function deriveAttackProfile(sheet: CharacterSheet, weaponIndex: number): if (entry.item.kind !== "weapon") { return { supported: false, reason: "unsupportedWeaponMode", weapon: snapshot }; } - if (entry.item.damage.type === "md") { - return { supported: false, reason: "unsupportedMdWeapon", weapon: snapshot }; - } - const kind: AttackKind = meleeCategories.has(entry.item.category) ? "melee" : "ranged"; const strikeBonusSources: ModifierSource[] = kind === "melee" @@ -331,7 +329,7 @@ export function deriveAttackProfile(sheet: CharacterSheet, weaponIndex: number): damageFormula: entry.item.damage.formula, damageBonus: kind === "melee" ? sheet.combat.damageBonus : 0, criticalOn: kind === "melee" ? sheet.combat.criticalStrikeOn : 20, - damageType: "sdc", + damageType: entry.item.damage.type, weapon: { ...snapshot, category: entry.item.category }, }; } @@ -436,7 +434,6 @@ export function deriveProtection(sheet: CharacterSheet): ProtectionState { const armor = sheet.armor; if (armor === undefined) return { kind: "none" }; if (armor.item.mdc !== undefined) { - if (armor.current === 0) return { kind: "none" }; return { kind: "mdcArmor", itemId: armor.item.id, diff --git a/packages/rules/src/schema/combat-exchange.ts b/packages/rules/src/schema/combat-exchange.ts index b0518ee..b5359e9 100644 --- a/packages/rules/src/schema/combat-exchange.ts +++ b/packages/rules/src/schema/combat-exchange.ts @@ -69,8 +69,10 @@ export type CombatResponseInput = z.infer; export const combatExchangeErrorCodeSchema = z.enum([ "selfTarget", + "combatantDead", "attackerNotReady", "defenderNotReady", + "armorNotReady", "weaponMissingOrChanged", "unsupportedWeaponMode", "unsupportedMdWeapon", diff --git a/packages/rules/tests/combat-exchange.test.ts b/packages/rules/tests/combat-exchange.test.ts index 62b70ea..e234841 100644 --- a/packages/rules/tests/combat-exchange.test.ts +++ b/packages/rules/tests/combat-exchange.test.ts @@ -3,6 +3,7 @@ import { attackerCombatStateToken, armorSchema, authorizeCombatResponse, + combatExchangeErrorCodeSchema, combatExchangeRules, combatExchangeRulesSchema, defenderCombatStateToken, @@ -153,6 +154,15 @@ describe("combat exchange constants", () => { ])("rejects an altered %s literal", (_name, candidate) => { expect(combatExchangeRulesSchema.safeParse(candidate).success).toBe(false); }); + + test("accepts new readiness errors while retaining legacy unsupported errors", () => { + expect(combatExchangeErrorCodeSchema.parse("combatantDead")).toBe("combatantDead"); + expect(combatExchangeErrorCodeSchema.parse("armorNotReady")).toBe("armorNotReady"); + expect(combatExchangeErrorCodeSchema.parse("unsupportedMdWeapon")).toBe("unsupportedMdWeapon"); + expect(combatExchangeErrorCodeSchema.parse("unsupportedMdcProtection")).toBe( + "unsupportedMdcProtection", + ); + }); }); describe("combat-state tokens", () => { @@ -220,7 +230,7 @@ describe("combat-state tokens", () => { ], ]); expect(JSON.parse(defenderCombatStateToken(first))).toEqual([ - "defender-v1", + "defender-v2", first.level, [ first.attributes.IQ, @@ -248,8 +258,9 @@ describe("combat-state tokens", () => { first.vitals.hitPoints.rolled, first.vitals.hitPoints.current, first.vitals.comaDeathFloor, + first.vitals.lifeState, ], - [armor.item.id, "mdc", null, armor.max, armor.current], + [armor.item.id, "mdc", true, null, armor.max, armor.current], ]); }); @@ -328,7 +339,7 @@ describe("combat-state tokens", () => { ); }); - test("fingerprints selected weapon mechanics, attack sources, and page-stamped rules", () => { + test("fingerprints selected weapon tier, mechanics, attack sources, and page-stamped rules", () => { const base = tokenSheet(); const selected = base.equipment[0]!; if (selected.item.kind !== "weapon") @@ -413,7 +424,7 @@ describe("combat-state tokens", () => { } }); - test("stales defenses for every rolled/current body pool and the coma floor", () => { + test("stales defenses for every rolled/current body pool, life state, and the coma floor", () => { const base = tokenSheet(); const baseToken = defenderCombatStateToken(base); const changedVitals = [ @@ -428,6 +439,7 @@ describe("combat-state tokens", () => { hitPoints: { ...base.vitals.hitPoints, current: base.vitals.hitPoints.current! - 1 }, }, { ...base.vitals, comaDeathFloor: base.vitals.comaDeathFloor - 1 }, + { ...base.vitals, lifeState: "coma" as const }, ]; for (const vitals of changedVitals) { @@ -480,6 +492,28 @@ describe("combat-state tokens", () => { expect(defenderCombatStateToken({ ...armored, armor })).not.toBe(baseToken); } }); + + test("stales defenses for M.D.C. readiness, maximum, and current capacity", () => { + const ready = tokenSheet(); + const baseToken = defenderCombatStateToken(ready); + const bodyCurrent = { hitPoints: 16, sdc: 18, ppe: 70 } as const; + const variants = [ + tokenSheet({ + items: [tokenItems[0], { itemId: "llw-concealed-light", worn: true }], + current: bodyCurrent, + }), + tokenSheet({ + items: [tokenItems[0], { itemId: "llw-concealed-light", worn: true, rolledMdc: 41 }], + current: { ...bodyCurrent, armor: 36 }, + }), + tokenSheet({ current: { ...tokenCurrent, armor: 35 } }), + tokenSheet({ current: { ...tokenCurrent, armor: 0 } }), + ]; + + for (const variant of variants) { + expect(defenderCombatStateToken(variant)).not.toBe(baseToken); + } + }); }); describe("weapon attack profiles", () => { @@ -510,15 +544,43 @@ describe("weapon attack profiles", () => { }); }); - test("refuses M.D. weapons and non-weapons without inventing modes", () => { + test("authorizes catalog M.D. energy pistols and rifles with their printed profiles", () => { const sheet = combatSheet({ - items: [{ itemId: "wilks-320-laser-pistol" }, { itemId: "canteen" }], + items: [{ itemId: "wilks-320-laser-pistol" }, { itemId: "wilks-447-laser-rifle" }], }); expect(deriveAttackProfile(sheet, 0)).toMatchObject({ - supported: false, - reason: "unsupportedMdWeapon", + supported: true, + kind: "ranged", + minimumStrikeTotal: 8, + strikeBonus: 0, + proficiencyBonus: 0, + damageFormula: "1D6", + damageBonus: 0, + criticalOn: 20, + damageType: "md", + weapon: { category: "energyPistol" }, + }); + expect(deriveAttackProfile(sheet, 1)).toMatchObject({ + supported: true, + kind: "ranged", + minimumStrikeTotal: 8, + strikeBonus: 0, + proficiencyBonus: 0, + damageFormula: "3D6", + damageBonus: 0, + criticalOn: 20, + damageType: "md", + weapon: { category: "energyRifle" }, }); + }); + + test("refuses non-weapons and missing instances without inventing modes", () => { + const sheet = combatSheet({ items: [{ itemId: "canteen" }] }); expect(deriveAttackProfile(sheet, 1)).toMatchObject({ + supported: false, + reason: "weaponMissingOrChanged", + }); + expect(deriveAttackProfile(sheet, 0)).toMatchObject({ supported: false, reason: "unsupportedWeaponMode", }); @@ -861,16 +923,30 @@ describe("protection classification", () => { expect(deriveProtection(combatSheet({ items: [] }))).toEqual({ kind: "none" }); }); - test("classifies fixed and unrolled dice-capacity M.D.C. suits without conversion", () => { - expect(deriveProtection(combatSheet({ items: [{ itemId: "gladiator", worn: true }] }))).toEqual( - { + test.each([ + ["full", undefined, 70], + ["partial", 35, 35], + ["depleted", 0, 0], + ] as const)( + "preserves %s fixed-capacity M.D.C. armor as protection", + (_state, current, expected) => { + const sheet = deriveSheet({ + ...combatant, + items: [{ itemId: "gladiator", worn: true }], + ...(current === undefined ? {} : { current: { armor: current } }), + }); + + expect(deriveProtection(sheet)).toEqual({ kind: "mdcArmor", itemId: "gladiator", name: "Gladiator Full Environmental Body Armor", max: 70, - current: 70, - }, - ); + current: expected, + }); + }, + ); + + test("preserves unrolled dice-capacity M.D.C. armor with absent pools", () => { expect( deriveProtection(combatSheet({ items: [{ itemId: "llw-concealed-light", worn: true }] })), ).toEqual({ @@ -879,6 +955,24 @@ describe("protection classification", () => { name: "Ley Line Walker Concealed Armor (Light)", }); }); + + test("continues to collapse depleted S.D.C. armor to no protection", () => { + const fixture = armorSchema.parse({ + kind: "armor", + id: "depleted-sdc-armor", + name: "Depleted S.D.C. Armor", + ar: 12, + sdc: 30, + page: 287, + }); + + expect( + deriveProtection({ + ...combatSheet({ items: [] }), + armor: { item: fixture, max: 30, current: 0 }, + }), + ).toEqual({ kind: "none" }); + }); }); describe("S.D.C. hit routing", () => { From bad7e04c409336c7b9cd7a6c4966097153548903 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 18:56:41 +0800 Subject: [PATCH 06/25] fix(rules): fingerprint mega-damage exchange rules --- packages/rules/src/engine/combat-exchange.ts | 8 +++ packages/rules/tests/combat-exchange.test.ts | 75 +++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/rules/src/engine/combat-exchange.ts b/packages/rules/src/engine/combat-exchange.ts index 147c2e6..975631d 100644 --- a/packages/rules/src/engine/combat-exchange.ts +++ b/packages/rules/src/engine/combat-exchange.ts @@ -27,13 +27,21 @@ function orderedCombatExchangeRules(rules: typeof combatExchangeRules) { rules.book, [ rules.pages.armorAndVitals, + rules.pages.megaDamageIntro, rules.pages.sdcCombat, rules.pages.defense, rules.pages.damage, rules.pages.automaticDodge, + rules.pages.megaDamageCombat, rules.pages.modernWeapons, rules.pages.rangedDodging, ], + [ + rules.rules.sdcPerMd, + rules.rules.minimumSdcToDamageMdc, + rules.rules.depletedMdcArmorBypassStrike, + rules.rules.finalMdcAbsorbsDestroyingBlast, + ], [rules.minimumStrikeTotal.melee, rules.minimumStrikeTotal.ranged], [ rules.rangedDodgeModifier.pointBlank, diff --git a/packages/rules/tests/combat-exchange.test.ts b/packages/rules/tests/combat-exchange.test.ts index e234841..e11436a 100644 --- a/packages/rules/tests/combat-exchange.test.ts +++ b/packages/rules/tests/combat-exchange.test.ts @@ -197,7 +197,13 @@ describe("combat-state tokens", () => { first.combat.strikeGuns, first.combat.criticalStrikeOn, ], - [combatExchangeRules.book, [287, 339, 340, 341, 344, 360, 361], [5, 8], [-10, -5, 0]], + [ + combatExchangeRules.book, + [287, 288, 339, 340, 341, 344, 355, 360, 361], + [100, 100, 8, true], + [5, 8], + [-10, -5, 0], + ], 0, [ "weapon", @@ -394,6 +400,73 @@ describe("combat-state tokens", () => { expect(attackerCombatStateToken(base, 0, changedRules)).not.toBe(baseToken); }); + test.each([ + [ + "mega-damage introduction page", + { + ...combatExchangeRules, + pages: { + ...combatExchangeRules.pages, + megaDamageIntro: combatExchangeRules.pages.megaDamageIntro + 1, + }, + }, + ], + [ + "mega-damage combat page", + { + ...combatExchangeRules, + pages: { + ...combatExchangeRules.pages, + megaDamageCombat: combatExchangeRules.pages.megaDamageCombat + 1, + }, + }, + ], + [ + "S.D.C.-per-M.D. ratio", + { + ...combatExchangeRules, + rules: { ...combatExchangeRules.rules, sdcPerMd: combatExchangeRules.rules.sdcPerMd + 1 }, + }, + ], + [ + "minimum S.D.C. needed to damage M.D.C.", + { + ...combatExchangeRules, + rules: { + ...combatExchangeRules.rules, + minimumSdcToDamageMdc: combatExchangeRules.rules.minimumSdcToDamageMdc + 1, + }, + }, + ], + [ + "depleted M.D.C. armor bypass strike", + { + ...combatExchangeRules, + rules: { + ...combatExchangeRules.rules, + depletedMdcArmorBypassStrike: combatExchangeRules.rules.depletedMdcArmorBypassStrike + 1, + }, + }, + ], + [ + "final M.D.C. absorption rule", + { + ...combatExchangeRules, + rules: { + ...combatExchangeRules.rules, + finalMdcAbsorbsDestroyingBlast: !combatExchangeRules.rules.finalMdcAbsorbsDestroyingBlast, + }, + }, + ], + ])("stales attacker-v2 when the %s changes", (_name, changedRules) => { + const sheet = tokenSheet(); + const baseToken = attackerCombatStateToken(sheet, 0); + + expect( + attackerCombatStateToken(sheet, 0, changedRules as unknown as typeof combatExchangeRules), + ).not.toBe(baseToken); + }); + test("stales defenses for level, every attribute, and every defense-profile dimension", () => { const base = tokenSheet(); const baseToken = defenderCombatStateToken(base); From 8237549fe363f1e466b961a6fddf43d3c434f4a5 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 19:18:43 +0800 Subject: [PATCH 07/25] feat(rules): resolve tiered combat damage --- packages/rules/src/engine/combat-exchange.ts | 243 ++++++++-- packages/rules/tests/combat-exchange.test.ts | 468 ++++++++++++++++--- 2 files changed, 612 insertions(+), 99 deletions(-) diff --git a/packages/rules/src/engine/combat-exchange.ts b/packages/rules/src/engine/combat-exchange.ts index 975631d..7dc4e84 100644 --- a/packages/rules/src/engine/combat-exchange.ts +++ b/packages/rules/src/engine/combat-exchange.ts @@ -10,7 +10,7 @@ import { import type { WeaponCategory } from "../schema/items.ts"; import type { DefenseKind } from "../schema/strike-resolution.ts"; import type { CharacterSheet, SheetEquipmentEntry } from "./character.ts"; -import { applyDamage, type VitalsPool } from "./combat.ts"; +import { applyBodyDamage, type VitalsPool } from "./combat.ts"; import { parseDice } from "./dice.ts"; import { damageArmor } from "./items.ts"; import type { D20Roll, DamageRoll } from "./rolls.ts"; @@ -247,6 +247,55 @@ export type SdcDamageRoute = } | { kind: "unsupportedMdcProtection" }; +export type DamageAmount = { type: "sdc" | "md"; value: number }; + +export type ProtectionDamageSnapshot = { + kind: "sdcArmor" | "mdcArmor"; + itemId: string; + name: string; + before: number; + after: number; +}; + +export type BodyDamageSnapshot = { before: VitalsPool; after: VitalsPool }; + +export type TieredDamageRoute = + | { + routingVersion: 2; + kind: "stopped"; + reason: "intactMdcImpervious" | "depletedMdcShell"; + nativeDamage: DamageAmount; + armor: ProtectionDamageSnapshot; + body: BodyDamageSnapshot; + } + | { + routingVersion: 2; + kind: "armor"; + nativeDamage: DamageAmount; + convertedDamage?: DamageAmount; + armor: ProtectionDamageSnapshot; + body: BodyDamageSnapshot; + finalBlastAbsorbed: boolean; + } + | { + routingVersion: 2; + kind: "body"; + nativeDamage: DamageAmount; + convertedDamage?: DamageAmount; + armor?: ProtectionDamageSnapshot; + body: BodyDamageSnapshot; + lifeState: { before: "alive" | "coma"; after: "alive" | "coma" }; + } + | { + routingVersion: 2; + kind: "fatal"; + nativeDamage: DamageAmount; + convertedDamage?: DamageAmount; + armor?: ProtectionDamageSnapshot; + body: BodyDamageSnapshot; + lifeState: { before: "alive" | "coma"; after: "dead" }; + }; + export type CombatExchangeResolution = | { outcome: "miss"; @@ -271,7 +320,7 @@ export type CombatExchangeResolution = damageMultiplier: 1 | 2; damageRoll: DamageRoll; totalDamage: number; - route: Exclude; + route: TieredDamageRoute; }; export interface ResolveCombatExchangeInput { @@ -477,39 +526,163 @@ export function evaluateDeclaration( : { status: "pendingDefense" }; } -export function routeSdcHit(input: { +const sdcToMd = (value: number): DamageAmount => ({ + type: "md", + value: Math.floor(value / combatExchangeRules.rules.sdcPerMd), +}); + +const mdToSdc = (value: number): DamageAmount => ({ + type: "sdc", + value: value * combatExchangeRules.rules.sdcPerMd, +}); + +function protectionSnapshot( + protection: Extract, + before: number, + after: number, +): ProtectionDamageSnapshot { + return { + kind: protection.kind, + itemId: protection.itemId, + name: protection.name, + before, + after, + }; +} + +function routeBodyDamage(input: { + nativeDamage: DamageAmount; + appliedDamage: DamageAmount; + armor?: ProtectionDamageSnapshot; + body: VitalsPool; + comaDeathFloor: number; +}): Extract { + const before = { ...input.body }; + const result = applyBodyDamage(input.body, input.appliedDamage.value, input.comaDeathFloor); + const beforeLifeState = before.hitPoints <= 0 ? "coma" : "alive"; + const common = { + routingVersion: 2 as const, + nativeDamage: input.nativeDamage, + ...(input.nativeDamage.type === input.appliedDamage.type + ? {} + : { convertedDamage: input.appliedDamage }), + ...(input.armor === undefined ? {} : { armor: input.armor }), + body: { before, after: result.after }, + }; + return result.lifeState === "dead" + ? { ...common, kind: "fatal", lifeState: { before: beforeLifeState, after: "dead" } } + : { + ...common, + kind: "body", + lifeState: { before: beforeLifeState, after: result.lifeState }, + }; +} + +export function routeCombatHit(input: { strikeTotal: number; - damage: number; + damage: DamageAmount; protection: ProtectionState; body: VitalsPool; comaDeathFloor: number; -}): SdcDamageRoute { - if (input.protection.kind === "mdcArmor") { - return { kind: "unsupportedMdcProtection" }; - } +}): TieredDamageRoute { + const nativeDamage = input.damage; const before = { ...input.body }; - if ( - input.protection.kind === "sdcArmor" && - input.protection.current > 0 && - input.strikeTotal <= input.protection.ar - ) { - return { - kind: "armor", - armor: { - before: input.protection.current, - after: damageArmor(input.protection.current, input.damage), - }, - body: { before, after: { ...before } }, - }; + + if (input.protection.kind === "sdcArmor") { + const appliedDamage = nativeDamage.type === "md" ? mdToSdc(nativeDamage.value) : nativeDamage; + const armor = protectionSnapshot( + input.protection, + input.protection.current, + input.protection.current, + ); + if (input.protection.current > 0 && input.strikeTotal <= input.protection.ar) { + const after = damageArmor(input.protection.current, appliedDamage.value); + return { + routingVersion: 2, + kind: "armor", + nativeDamage, + ...(nativeDamage.type === "md" ? { convertedDamage: appliedDamage } : {}), + armor: { ...armor, after }, + body: { before, after: { ...before } }, + finalBlastAbsorbed: after === 0, + }; + } + return routeBodyDamage({ + nativeDamage, + appliedDamage, + armor, + body: input.body, + comaDeathFloor: input.comaDeathFloor, + }); } - const after = applyDamage(input.body, input.damage, input.comaDeathFloor); - return { - kind: "body", - ...(input.protection.kind === "sdcArmor" - ? { armor: { before: input.protection.current, after: input.protection.current } } - : {}), - body: { before, after }, - }; + + if (input.protection.kind === "mdcArmor") { + if (input.protection.current === undefined) { + throw new Error("armorNotReady: M.D.C. protection requires a current capacity."); + } + if (input.protection.current > 0) { + const armor = protectionSnapshot( + input.protection, + input.protection.current, + input.protection.current, + ); + if ( + nativeDamage.type === "sdc" && + nativeDamage.value < combatExchangeRules.rules.minimumSdcToDamageMdc + ) { + return { + routingVersion: 2, + kind: "stopped", + reason: "intactMdcImpervious", + nativeDamage, + armor, + body: { before, after: { ...before } }, + }; + } + const appliedDamage = + nativeDamage.type === "sdc" ? sdcToMd(nativeDamage.value) : nativeDamage; + const after = damageArmor(input.protection.current, appliedDamage.value); + return { + routingVersion: 2, + kind: "armor", + nativeDamage, + ...(nativeDamage.type === "sdc" ? { convertedDamage: appliedDamage } : {}), + armor: { ...armor, after }, + body: { before, after: { ...before } }, + finalBlastAbsorbed: after === 0, + }; + } + const armor = protectionSnapshot(input.protection, 0, 0); + if ( + nativeDamage.type === "sdc" && + input.strikeTotal < combatExchangeRules.rules.depletedMdcArmorBypassStrike + ) { + return { + routingVersion: 2, + kind: "stopped", + reason: "depletedMdcShell", + nativeDamage, + armor, + body: { before, after: { ...before } }, + }; + } + const appliedDamage = nativeDamage.type === "md" ? mdToSdc(nativeDamage.value) : nativeDamage; + return routeBodyDamage({ + nativeDamage, + appliedDamage, + armor, + body: input.body, + comaDeathFloor: input.comaDeathFloor, + }); + } + + const appliedDamage = nativeDamage.type === "md" ? mdToSdc(nativeDamage.value) : nativeDamage; + return routeBodyDamage({ + nativeDamage, + appliedDamage, + body: input.body, + comaDeathFloor: input.comaDeathFloor, + }); } function assertDamageRoll( @@ -537,9 +710,6 @@ function assertDamageRoll( export function resolveCombatExchange(input: ResolveCombatExchangeInput): CombatExchangeResolution { validateCombatContext(input.attack, input.context); - if (input.protection.kind === "mdcArmor") { - throw new Error("unsupportedMdcProtection: full M.D.C. resolution is out of scope."); - } const expectedStrikeBonus = input.attack.strikeBonus + (input.context.strikeModifier ?? 0); if (input.strikeRoll.bonus !== expectedStrikeBonus) { throw new Error(`Strike bonus must be ${expectedStrikeBonus}.`); @@ -579,7 +749,7 @@ export function resolveCombatExchange(input: ResolveCombatExchangeInput): Combat }, }), allowedDefenses: takesHit ? [] : [input.response.kind as DefenseKind], - damageType: "sdc", + damageType: input.attack.damageType, criticalOn: input.attack.criticalOn, }); if (strike.outcome !== "hit") { @@ -600,16 +770,13 @@ export function resolveCombatExchange(input: ResolveCombatExchangeInput): Combat } assertDamageRoll(input.attack, input.damageRoll); const totalDamage = input.damageRoll.total * strike.damageMultiplier; - const route = routeSdcHit({ + const route = routeCombatHit({ strikeTotal: input.strikeRoll.total, - damage: totalDamage, + damage: { type: input.attack.damageType, value: totalDamage }, protection: input.protection, body: input.body, comaDeathFloor: input.comaDeathFloor, }); - if (route.kind === "unsupportedMdcProtection") { - throw new Error("unsupportedMdcProtection: full M.D.C. resolution is out of scope."); - } return { outcome: "hit", reason: strike.reason as "unopposed" | "strikeWon", diff --git a/packages/rules/tests/combat-exchange.test.ts b/packages/rules/tests/combat-exchange.test.ts index e11436a..f51b086 100644 --- a/packages/rules/tests/combat-exchange.test.ts +++ b/packages/rules/tests/combat-exchange.test.ts @@ -13,7 +13,7 @@ import { deriveSheet, evaluateDeclaration, resolveCombatExchange, - routeSdcHit, + routeCombatHit, validateCombatContext, type AttackProfile, type CharacterInput, @@ -1048,7 +1048,7 @@ describe("protection classification", () => { }); }); -describe("S.D.C. hit routing", () => { +describe("tiered hit routing", () => { const fixture = armorSchema.parse({ kind: "armor", id: "test-sdc-armor", @@ -1066,80 +1066,329 @@ describe("S.D.C. hit routing", () => { current: 5, }; - test("a strike at A.R. ablates armor without spilling destruction damage", () => { - expect( - routeSdcHit({ - strikeTotal: 12, - damage: 9, - protection: armor, - body: { sdc: 10, hitPoints: 20 }, - comaDeathFloor: -10, - }), - ).toEqual({ - kind: "armor", - armor: { before: 5, after: 0 }, - body: { - before: { sdc: 10, hitPoints: 20 }, - after: { sdc: 10, hitPoints: 20 }, - }, - }); - }); + test.each([ + ["S.D.C. below", 11, { type: "sdc", value: 9 }, undefined], + ["S.D.C. at", 12, { type: "sdc", value: 9 }, undefined], + ["M.D. below", 11, { type: "md", value: 1 }, { type: "sdc", value: 100 }], + ["M.D. at", 12, { type: "md", value: 1 }, { type: "sdc", value: 100 }], + ] as const)( + "a %s A.R. strike ablates S.D.C. armor without spilling", + (_case, strikeTotal, damage, convertedDamage) => { + expect( + routeCombatHit({ + strikeTotal, + damage, + protection: armor, + body: { sdc: 10, hitPoints: 20 }, + comaDeathFloor: -10, + }), + ).toEqual({ + routingVersion: 2, + kind: "armor", + nativeDamage: damage, + ...(convertedDamage === undefined ? {} : { convertedDamage }), + armor: { + kind: "sdcArmor", + itemId: fixture.id, + name: fixture.name, + before: 5, + after: 0, + }, + body: { + before: { sdc: 10, hitPoints: 20 }, + after: { sdc: 10, hitPoints: 20 }, + }, + finalBlastAbsorbed: true, + }); + }, + ); - test("a strike above A.R. routes the full hit to the body without changing armor", () => { - expect( - routeSdcHit({ - strikeTotal: 13, - damage: 9, - protection: armor, - body: { sdc: 10, hitPoints: 20 }, - comaDeathFloor: -10, - }), - ).toEqual({ - kind: "body", - armor: { before: 5, after: 5 }, - body: { - before: { sdc: 10, hitPoints: 20 }, - after: { sdc: 1, hitPoints: 20 }, - }, - }); - }); + test.each([ + ["S.D.C.", { type: "sdc", value: 9 }, undefined, { sdc: 1, hitPoints: 20 }], + ["M.D.", { type: "md", value: 1 }, { type: "sdc", value: 100 }, { sdc: 0, hitPoints: -10 }], + ] as const)( + "a %s strike above A.R. routes the full hit to the body without changing armor", + (_tier, damage, convertedDamage, after) => { + expect( + routeCombatHit({ + strikeTotal: 13, + damage, + protection: armor, + body: { sdc: 10, hitPoints: 20 }, + comaDeathFloor: -10, + }), + ).toEqual({ + routingVersion: 2, + kind: damage.type === "md" ? "fatal" : "body", + nativeDamage: damage, + ...(convertedDamage === undefined ? {} : { convertedDamage }), + armor: { + kind: "sdcArmor", + itemId: fixture.id, + name: fixture.name, + before: 5, + after: 5, + }, + body: { + before: { sdc: 10, hitPoints: 20 }, + after, + }, + lifeState: { + before: "alive", + after: damage.type === "md" ? "dead" : "alive", + }, + }); + }, + ); test("depleted armor routes future hits through body S.D.C. before Hit Points", () => { expect( - routeSdcHit({ + routeCombatHit({ strikeTotal: 12, - damage: 5, + damage: { type: "sdc", value: 5 }, protection: { ...armor, current: 0 }, body: { sdc: 3, hitPoints: 20 }, comaDeathFloor: -10, }), ).toEqual({ + routingVersion: 2, kind: "body", - armor: { before: 0, after: 0 }, + nativeDamage: { type: "sdc", value: 5 }, + armor: { + kind: "sdcArmor", + itemId: fixture.id, + name: fixture.name, + before: 0, + after: 0, + }, body: { before: { sdc: 3, hitPoints: 20 }, after: { sdc: 0, hitPoints: 18 }, }, + lifeState: { before: "alive", after: "alive" }, }); }); - test("refuses M.D.C. protection instead of converting the hit", () => { - expect( - routeSdcHit({ + const mdcArmor: ProtectionState = { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + max: 70, + current: 10, + }; + + test.each([ + [99, "stopped", undefined, 10], + [100, "armor", 1, 9], + [199, "armor", 1, 9], + [200, "armor", 2, 8], + [450, "armor", 4, 6], + [496, "armor", 4, 6], + ] as const)( + "routes %i S.D.C. against intact M.D.C. armor as %s", + (value, kind, convertedValue, after) => { + const result = routeCombatHit({ strikeTotal: 20, - damage: 100, - protection: { + damage: { type: "sdc", value }, + protection: mdcArmor, + body: { sdc: 10, hitPoints: 20 }, + comaDeathFloor: -10, + }); + + expect(result).toEqual( + kind === "stopped" + ? { + routingVersion: 2, + kind: "stopped", + reason: "intactMdcImpervious", + nativeDamage: { type: "sdc", value }, + armor: { + kind: "mdcArmor", + itemId: mdcArmor.itemId, + name: mdcArmor.name, + before: 10, + after: 10, + }, + body: { + before: { sdc: 10, hitPoints: 20 }, + after: { sdc: 10, hitPoints: 20 }, + }, + } + : { + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "sdc", value }, + convertedDamage: { type: "md", value: convertedValue }, + armor: { + kind: "mdcArmor", + itemId: mdcArmor.itemId, + name: mdcArmor.name, + before: 10, + after, + }, + body: { + before: { sdc: 10, hitPoints: 20 }, + after: { sdc: 10, hitPoints: 20 }, + }, + finalBlastAbsorbed: false, + }, + ); + }, + ); + + test.each([ + [ + { type: "sdc", value: 200 }, + { type: "md", value: 2 }, + ], + [{ type: "md", value: 21 }, undefined], + ] as const)( + "absorbs the full %s final M.D.C. blast without body spill", + (damage, convertedDamage) => { + expect( + routeCombatHit({ + strikeTotal: 20, + damage, + protection: { ...mdcArmor, current: 1 }, + body: { sdc: 10, hitPoints: 20 }, + comaDeathFloor: -10, + }), + ).toEqual({ + routingVersion: 2, + kind: "armor", + nativeDamage: damage, + ...(convertedDamage === undefined ? {} : { convertedDamage }), + armor: { kind: "mdcArmor", - itemId: "gladiator", - name: "Gladiator Full Environmental Body Armor", - max: 70, - current: 70, + itemId: mdcArmor.itemId, + name: mdcArmor.name, + before: 1, + after: 0, }, - body: { sdc: 10, hitPoints: 20 }, + body: { + before: { sdc: 10, hitPoints: 20 }, + after: { sdc: 10, hitPoints: 20 }, + }, + finalBlastAbsorbed: true, + }); + }, + ); + + test("a depleted M.D.C. shell stops an S.D.C. strike total of 7", () => { + expect( + routeCombatHit({ + strikeTotal: 7, + damage: { type: "sdc", value: 5 }, + protection: { ...mdcArmor, current: 0 }, + body: { sdc: 120, hitPoints: 20 }, comaDeathFloor: -10, }), - ).toEqual({ kind: "unsupportedMdcProtection" }); + ).toEqual({ + routingVersion: 2, + kind: "stopped", + reason: "depletedMdcShell", + nativeDamage: { type: "sdc", value: 5 }, + armor: { + kind: "mdcArmor", + itemId: mdcArmor.itemId, + name: mdcArmor.name, + before: 0, + after: 0, + }, + body: { + before: { sdc: 120, hitPoints: 20 }, + after: { sdc: 120, hitPoints: 20 }, + }, + }); }); + + test.each([ + [8, { type: "sdc", value: 5 }, undefined, { sdc: 115, hitPoints: 20 }], + [7, { type: "md", value: 1 }, { type: "sdc", value: 100 }, { sdc: 20, hitPoints: 20 }], + ] as const)( + "a depleted M.D.C. shell admits strike %i with %s damage", + (strikeTotal, damage, convertedDamage, after) => { + expect( + routeCombatHit({ + strikeTotal, + damage, + protection: { ...mdcArmor, current: 0 }, + body: { sdc: 120, hitPoints: 20 }, + comaDeathFloor: -10, + }), + ).toEqual({ + routingVersion: 2, + kind: "body", + nativeDamage: damage, + ...(convertedDamage === undefined ? {} : { convertedDamage }), + armor: { + kind: "mdcArmor", + itemId: mdcArmor.itemId, + name: mdcArmor.name, + before: 0, + after: 0, + }, + body: { + before: { sdc: 120, hitPoints: 20 }, + after, + }, + lifeState: { before: "alive", after: "alive" }, + }); + }, + ); + + test.each([ + [ + "S.D.C. before Hit Points", + { type: "sdc", value: 5 }, + undefined, + { sdc: 3, hitPoints: 5 }, + -5, + "body", + { sdc: 0, hitPoints: 3 }, + "alive", + ], + [ + "exact coma floor", + { type: "md", value: 1 }, + { type: "sdc", value: 100 }, + { sdc: 90, hitPoints: 5 }, + -5, + "body", + { sdc: 0, hitPoints: -5 }, + "coma", + ], + [ + "fatal overflow", + { type: "md", value: 1 }, + { type: "sdc", value: 100 }, + { sdc: 89, hitPoints: 5 }, + -5, + "fatal", + { sdc: 0, hitPoints: -5 }, + "dead", + ], + ] as const)( + "routes no-armor damage through %s", + (_case, damage, convertedDamage, before, comaDeathFloor, kind, after, lifeAfter) => { + expect( + routeCombatHit({ + strikeTotal: 20, + damage, + protection: { kind: "none" }, + body: before, + comaDeathFloor, + }), + ).toEqual({ + routingVersion: 2, + kind, + nativeDamage: damage, + ...(convertedDamage === undefined ? {} : { convertedDamage }), + body: { before, after }, + lifeState: { before: "alive", after: lifeAfter }, + }); + }, + ); }); describe("completed pure combat exchanges", () => { @@ -1236,11 +1485,14 @@ describe("completed pure combat exchanges", () => { damageRoll, totalDamage: 5, route: { + routingVersion: 2, kind: "body", + nativeDamage: { type: "sdc", value: 5 }, body: { before: { sdc: 10, hitPoints: 20 }, after: { sdc: 5, hitPoints: 20 }, }, + lifeState: { before: "alive", after: "alive" }, }, }); }); @@ -1252,15 +1504,23 @@ describe("completed pure combat exchanges", () => { strikeRoll: d20(20, meleeAttack.strikeBonus), damageRoll, }), - ).toMatchObject({ + ).toEqual({ outcome: "hit", + reason: "unopposed", + response: noneResponse, critical: true, damageMultiplier: 2, - damageRoll: { dice: [4], bonus: 1, total: 5 }, + damageRoll, totalDamage: 10, route: { + routingVersion: 2, kind: "body", - body: { after: { sdc: 0, hitPoints: 20 } }, + nativeDamage: { type: "sdc", value: 10 }, + body: { + before: { sdc: 10, hitPoints: 20 }, + after: { sdc: 0, hitPoints: 20 }, + }, + lifeState: { before: "alive", after: "alive" }, }, }); }); @@ -1290,11 +1550,71 @@ describe("completed pure combat exchanges", () => { body: { sdc: 20, hitPoints: 20 }, comaDeathFloor: -10, }), - ).toMatchObject({ + ).toEqual({ outcome: "hit", - damageRoll: { bonus: 0 }, + reason: "unopposed", + response, + critical: false, + damageMultiplier: 1, + damageRoll: firearmDamage, totalDamage: 10, - route: { body: { after: { sdc: 10, hitPoints: 20 } } }, + route: { + routingVersion: 2, + kind: "body", + nativeDamage: { type: "sdc", value: 10 }, + body: { + before: { sdc: 20, hitPoints: 20 }, + after: { sdc: 10, hitPoints: 20 }, + }, + lifeState: { before: "alive", after: "alive" }, + }, + }); + }); + + test("multiplies a critical M.D. roll before converting body damage", () => { + const attack = requireSupported( + deriveAttackProfile(combatSheet({ items: [{ itemId: "wilks-320-laser-pistol" }] }), 0), + ); + const context = { + kind: "ranged", + defenderAware: false, + rangeBand: "normal", + } as const; + const response = authorizeCombatResponse(deriveDefenseOptions(defender, attack, context), { + kind: "none", + }); + const mdDamage: DamageRoll = { dice: [4], bonus: 0, total: 4 }; + + expect( + resolveCombatExchange({ + attack, + context, + strikeRoll: d20(20, attack.strikeBonus), + response, + damageRoll: mdDamage, + protection: { kind: "none" }, + body: { sdc: 1_000, hitPoints: 20 }, + comaDeathFloor: -10, + }), + ).toEqual({ + outcome: "hit", + reason: "unopposed", + response, + critical: true, + damageMultiplier: 2, + damageRoll: mdDamage, + totalDamage: 8, + route: { + routingVersion: 2, + kind: "body", + nativeDamage: { type: "md", value: 8 }, + convertedDamage: { type: "sdc", value: 800 }, + body: { + before: { sdc: 1_000, hitPoints: 20 }, + after: { sdc: 200, hitPoints: 20 }, + }, + lifeState: { before: "alive", after: "alive" }, + }, }); }); @@ -1386,10 +1706,11 @@ describe("completed pure combat exchanges", () => { ).toThrow(/Damage total must be 5/i); }); - test("rejects M.D.C. protection before producing an S.D.C. route", () => { - expect(() => + test("emits a v2 stopped route instead of unsupported M.D.C. protection", () => { + expect( resolveCombatExchange({ ...unopposedMelee, + damageRoll, protection: { kind: "mdcArmor", itemId: "gladiator", @@ -1398,6 +1719,31 @@ describe("completed pure combat exchanges", () => { current: 70, }, }), - ).toThrow(/unsupportedMdcProtection.*out of scope/i); + ).toEqual({ + outcome: "hit", + reason: "unopposed", + response: noneResponse, + critical: false, + damageMultiplier: 1, + damageRoll, + totalDamage: 5, + route: { + routingVersion: 2, + kind: "stopped", + reason: "intactMdcImpervious", + nativeDamage: { type: "sdc", value: 5 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 70, + after: 70, + }, + body: { + before: { sdc: 10, hitPoints: 20 }, + after: { sdc: 10, hitPoints: 20 }, + }, + }, + }); }); }); From 4fcba42ec6526c1d80e10b94c8da1c6b98c7f552 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 19:31:42 +0800 Subject: [PATCH 08/25] feat(backend): validate tiered combat routes --- packages/backend/convex/combat_values.ts | 86 ++++++++-- packages/backend/convex/schema.ts | 1 + packages/backend/tests/combat.test.ts | 193 ++++++++++++++++++++++- 3 files changed, 269 insertions(+), 11 deletions(-) diff --git a/packages/backend/convex/combat_values.ts b/packages/backend/convex/combat_values.ts index 45ccea8..ee7308f 100644 --- a/packages/backend/convex/combat_values.ts +++ b/packages/backend/convex/combat_values.ts @@ -101,23 +101,89 @@ const exchangeBase = { strikeRoll: d20RollValidator, }; -const routeValidator = v.union( +const vitalsPoolValidator = v.object({ sdc: v.number(), hitPoints: v.number() }); + +const bodyDamageSnapshotValidator = v.object({ + before: vitalsPoolValidator, + after: vitalsPoolValidator, +}); + +const legacySdcDamageRouteValidator = v.union( v.object({ kind: v.literal("armor"), armor: v.object({ before: v.number(), after: v.number() }), - body: v.object({ - before: v.object({ sdc: v.number(), hitPoints: v.number() }), - after: v.object({ sdc: v.number(), hitPoints: v.number() }), - }), + body: bodyDamageSnapshotValidator, }), v.object({ kind: v.literal("body"), armor: v.optional(v.object({ before: v.number(), after: v.number() })), - body: v.object({ - before: v.object({ sdc: v.number(), hitPoints: v.number() }), - after: v.object({ sdc: v.number(), hitPoints: v.number() }), - }), + body: bodyDamageSnapshotValidator, + }), +); + +const damageAmountValidator = v.object({ + type: v.union(v.literal("sdc"), v.literal("md")), + value: v.number(), +}); + +const protectionDamageSnapshotValidator = v.object({ + kind: v.union(v.literal("sdcArmor"), v.literal("mdcArmor")), + itemId: v.string(), + name: v.string(), + before: v.number(), + after: v.number(), +}); + +const stoppedDamageRouteValidator = v.object({ + routingVersion: v.literal(2), + kind: v.literal("stopped"), + reason: v.union(v.literal("intactMdcImpervious"), v.literal("depletedMdcShell")), + nativeDamage: damageAmountValidator, + armor: protectionDamageSnapshotValidator, + body: bodyDamageSnapshotValidator, +}); + +const armorDamageRouteValidator = v.object({ + routingVersion: v.literal(2), + kind: v.literal("armor"), + nativeDamage: damageAmountValidator, + convertedDamage: v.optional(damageAmountValidator), + armor: protectionDamageSnapshotValidator, + body: bodyDamageSnapshotValidator, + finalBlastAbsorbed: v.boolean(), +}); + +const bodyDamageRouteValidator = v.object({ + routingVersion: v.literal(2), + kind: v.literal("body"), + nativeDamage: damageAmountValidator, + convertedDamage: v.optional(damageAmountValidator), + armor: v.optional(protectionDamageSnapshotValidator), + body: bodyDamageSnapshotValidator, + lifeState: v.object({ + before: v.union(v.literal("alive"), v.literal("coma")), + after: v.union(v.literal("alive"), v.literal("coma")), }), +}); + +const fatalDamageRouteValidator = v.object({ + routingVersion: v.literal(2), + kind: v.literal("fatal"), + nativeDamage: damageAmountValidator, + convertedDamage: v.optional(damageAmountValidator), + armor: v.optional(protectionDamageSnapshotValidator), + body: bodyDamageSnapshotValidator, + lifeState: v.object({ + before: v.union(v.literal("alive"), v.literal("coma")), + after: v.literal("dead"), + }), +}); + +const tieredDamageRouteValidator = v.union( + stoppedDamageRouteValidator, + armorDamageRouteValidator, + bodyDamageRouteValidator, + fatalDamageRouteValidator, ); export const resolvedResultValidator = v.union( @@ -144,7 +210,7 @@ export const resolvedResultValidator = v.union( damageMultiplier: v.union(v.literal(1), v.literal(2)), damageRoll: damageRollValidator, totalDamage: v.number(), - route: routeValidator, + route: v.union(legacySdcDamageRouteValidator, tieredDamageRouteValidator), }), ); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 8940618..2dcb272 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -68,6 +68,7 @@ export const characterFields = { ppe: v.optional(v.number()), armor: v.optional(v.number()), treatmentDays: v.optional(v.number()), + lifeState: v.optional(v.literal("dead")), }), ), narrative: v.optional( diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index cce4b5c..d194efc 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -8,7 +8,7 @@ import { import { convexTest } from "convex-test"; import { describe, expect, test, vi } from "vite-plus/test"; import { api } from "../convex/_generated/api"; -import type { Id } from "../convex/_generated/dataModel"; +import type { Doc, Id } from "../convex/_generated/dataModel"; import schema from "../convex/schema"; const modules = { @@ -148,6 +148,197 @@ function exchangeBase(attackerId: Id<"characters">, defenderId: Id<"characters"> }; } +type ResolvedExchange = Extract, { status: "resolved" }>; +type HitResolution = Extract; +type StoredDamageRoute = HitResolution["route"]; + +async function insertResolvedRoute( + t: TestDb, + characterId: Id<"characters">, + serial: number, + route: StoredDamageRoute, +) { + return t.run((ctx) => + ctx.db.insert("combatExchanges", { + ...exchangeBase(characterId, characterId, serial), + status: "resolved", + resolution: { + outcome: "hit", + reason: "unopposed", + response: { + kind: "none", + bonus: 0, + actionCost: 0, + explanation: "No defense.", + defenseModifier: 0, + totalBonus: 0, + }, + critical: false, + damageMultiplier: 1, + damageRoll: { dice: [4], total: 4, bonus: 0 }, + totalDamage: 4, + route, + }, + }), + ); +} + +async function readStoredRoute(t: TestDb, exchangeId: Id<"combatExchanges">) { + const exchange = await t.run((ctx) => ctx.db.get(exchangeId)); + if (exchange?.status !== "resolved" || exchange.resolution.outcome !== "hit") { + throw new Error("Expected a stored resolved hit exchange."); + } + return exchange.resolution.route; +} + +describe("combat persistence compatibility", () => { + const unchangedBody = { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, + }; + const mdcArmor = { + kind: "mdcArmor" as const, + itemId: "explorer-armor", + name: "Explorer Armor", + before: 10, + after: 6, + }; + + test.each([ + [ + "legacy armor", + { + kind: "armor", + armor: { before: 30, after: 26 }, + body: unchangedBody, + }, + ], + [ + "legacy body", + { + kind: "body", + body: { + before: { sdc: 3, hitPoints: 18 }, + after: { sdc: 0, hitPoints: 17 }, + }, + }, + ], + [ + "v2 stopped", + { + routingVersion: 2, + kind: "stopped", + reason: "intactMdcImpervious", + nativeDamage: { type: "sdc", value: 99 }, + armor: { ...mdcArmor, after: 10 }, + body: unchangedBody, + }, + ], + [ + "v2 armor", + { + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "md", value: 4 }, + armor: mdcArmor, + body: unchangedBody, + finalBlastAbsorbed: false, + }, + ], + [ + "v2 body", + { + routingVersion: 2, + kind: "body", + nativeDamage: { type: "md", value: 1 }, + convertedDamage: { type: "sdc", value: 100 }, + armor: { ...mdcArmor, before: 0, after: 0 }, + body: { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 0, hitPoints: -10 }, + }, + lifeState: { before: "alive", after: "coma" }, + }, + ], + [ + "v2 fatal", + { + routingVersion: 2, + kind: "fatal", + nativeDamage: { type: "sdc", value: 30 }, + body: { + before: { sdc: 0, hitPoints: 10 }, + after: { sdc: 0, hitPoints: -11 }, + }, + lifeState: { before: "coma", after: "dead" }, + }, + ], + ] satisfies ReadonlyArray)( + "inserts and reads the $0 route unchanged", + async (_label, route) => { + const t = testDb(); + const characterId = await createCharacter(t); + + const exchangeId = await insertResolvedRoute(t, characterId, 1, route); + + expect(await readStoredRoute(t, exchangeId)).toEqual(route); + }, + ); + + test.each([ + [ + "v2 route without native damage", + { + routingVersion: 2, + kind: "stopped", + reason: "depletedMdcShell", + armor: { ...mdcArmor, before: 0, after: 0 }, + body: unchangedBody, + }, + ], + [ + "fatal route ending in coma", + { + routingVersion: 2, + kind: "fatal", + nativeDamage: { type: "sdc", value: 30 }, + body: { + before: { sdc: 0, hitPoints: 10 }, + after: { sdc: 0, hitPoints: -10 }, + }, + lifeState: { before: "alive", after: "coma" }, + }, + ], + [ + "legacy route with v2-only fields", + { + kind: "armor", + nativeDamage: { type: "sdc", value: 4 }, + armor: { before: 30, after: 26 }, + body: unchangedBody, + }, + ], + ] as const)("rejects a $0", async (_label, route) => { + const t = testDb(); + const characterId = await createCharacter(t); + + await expect( + insertResolvedRoute(t, characterId, 2, route as unknown as StoredDamageRoute), + ).rejects.toThrow(); + }); + + test("stores the optional dead marker in character current state", async () => { + const t = testDb(); + const current = { hitPoints: -11, sdc: 0, lifeState: "dead" as const }; + + const characterId = await t.run((ctx) => + ctx.db.insert("characters", { ...character, current }), + ); + + expect((await getCharacter(t, characterId))?.current).toEqual(current); + }); +}); + describe("combat target selector", () => { test("excludes self, stays bounded, and safely classifies readiness and protection", async () => { const t = convexTest(schema, modules); From 13da576e6a174c472a26462859ee95749b063da8 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 19:42:57 +0800 Subject: [PATCH 09/25] fix(backend): persist mega-damage attacks --- packages/backend/convex/combat_values.ts | 2 +- packages/backend/tests/combat.test.ts | 45 +++++++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/backend/convex/combat_values.ts b/packages/backend/convex/combat_values.ts index ee7308f..74d5377 100644 --- a/packages/backend/convex/combat_values.ts +++ b/packages/backend/convex/combat_values.ts @@ -93,7 +93,7 @@ const exchangeBase = { damageFormula: v.string(), damageBonus: v.number(), criticalOn: v.number(), - damageType: v.literal("sdc"), + damageType: v.union(v.literal("sdc"), v.literal("md")), }), context: combatContextValidator, attackerStateToken: v.string(), diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index d194efc..5a2facd 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -151,16 +151,20 @@ function exchangeBase(attackerId: Id<"characters">, defenderId: Id<"characters"> type ResolvedExchange = Extract, { status: "resolved" }>; type HitResolution = Extract; type StoredDamageRoute = HitResolution["route"]; +type StoredAttackSnapshot = Pick; async function insertResolvedRoute( t: TestDb, characterId: Id<"characters">, serial: number, route: StoredDamageRoute, + snapshot?: StoredAttackSnapshot, ) { + const base = exchangeBase(characterId, characterId, serial); return t.run((ctx) => ctx.db.insert("combatExchanges", { - ...exchangeBase(characterId, characterId, serial), + ...base, + ...snapshot, status: "resolved", resolution: { outcome: "hit", @@ -285,6 +289,45 @@ describe("combat persistence compatibility", () => { }, ); + test("inserts and reads an M.D. attack snapshot with its v2 route unchanged", async () => { + const t = testDb(); + const characterId = await createCharacter(t); + const route = { + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "md", value: 4 }, + armor: mdcArmor, + body: unchangedBody, + finalBlastAbsorbed: false, + } satisfies StoredDamageRoute; + const snapshot = { + weapon: { + index: 0, + itemId: "wilks-320-laser-pistol", + name: "Wilk's 320 Laser Pistol", + category: "energyPistol", + }, + attack: { + kind: "ranged", + minimumStrikeTotal: 8, + strikeBonus: 0, + strikeBonusSources: [{ source: "proficiency", label: "Modern W.P.", value: 0 }], + proficiencyBonus: 0, + damageFormula: "1D6", + damageBonus: 0, + criticalOn: 20, + damageType: "md", + }, + context: { kind: "ranged", defenderAware: true, rangeBand: "normal" }, + } satisfies StoredAttackSnapshot; + + const exchangeId = await insertResolvedRoute(t, characterId, 3, route, snapshot); + const exchange = await t.run((ctx) => ctx.db.get(exchangeId)); + + expect(exchange?.attack.damageType).toBe("md"); + expect(await readStoredRoute(t, exchangeId)).toEqual(route); + }); + test.each([ [ "v2 route without native damage", From 2b6cadb2e292521aafbc8966f5d3e6c958496eba Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 19:57:37 +0800 Subject: [PATCH 10/25] fix(rules): store terminal state with current pools --- packages/rules/src/engine/character.ts | 2 +- packages/rules/src/schema/character.ts | 6 +++--- packages/rules/tests/character.test.ts | 24 +++++++++++++++++------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index 403ac7d..7c136f5 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -169,7 +169,7 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { const sdc = withRolled(physicalSdcRange(), character.rolled?.sdc, character.current?.sdc); let lifeState: LifeState; - if (character.lifeState === "dead") { + if (character.current?.lifeState === "dead") { if (hitPoints.rolled === undefined || sdc.rolled === undefined) { throw new Error("lifeState dead requires rolled vitals for H.P. and S.D.C."); } diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts index ca270d5..ee6670d 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -146,11 +146,11 @@ export const characterSchema = z.object({ * fresh course. When a NEW course starts within one set of pools is * GM adjudication (the treat mutation's explicit `day` override). */ treatmentDays: z.number().int().nonnegative().optional(), + /** Persisted only for the terminal state. Alive/coma are derived from + * live H.P.; `deriveSheet` validates that dead agrees with the pools. */ + lifeState: z.literal("dead").optional(), }) .optional(), - /** Persisted only for the terminal state. Alive/coma are derived from live - * H.P.; `deriveSheet` validates that dead agrees with the body pools. */ - lifeState: z.literal("dead").optional(), /** Optional player-authored identity; passed through to the sheet untouched. */ narrative: narrativeSchema.optional(), }); diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index a887ee5..f79ba6c 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -118,33 +118,43 @@ describe("deriveSheet — edge cases", () => { const sheet = deriveSheet({ ...leyLineWalker, rolled: { hitPoints: 18, sdc: 20 }, - current: { hitPoints: -14, sdc: 0 }, - lifeState: "dead", + current: { hitPoints: -14, sdc: 0, lifeState: "dead" }, }); expect(sheet.vitals.lifeState).toBe("dead"); }); test("rejects unrolled or contradictory dead markers", () => { - expect(() => deriveSheet({ ...leyLineWalker, lifeState: "dead" })).toThrow(/rolled vitals/i); + expect(() => deriveSheet({ ...leyLineWalker, current: { lifeState: "dead" } })).toThrow( + /rolled vitals/i, + ); expect(() => deriveSheet({ ...leyLineWalker, rolled: { hitPoints: 18, sdc: 20 }, - current: { hitPoints: -14, sdc: 1 }, - lifeState: "dead", + current: { hitPoints: -14, sdc: 1, lifeState: "dead" }, }), ).toThrow(/S\.D\.C\./); expect(() => deriveSheet({ ...leyLineWalker, rolled: { hitPoints: 18, sdc: 20 }, - current: { hitPoints: 0, sdc: 0 }, - lifeState: "dead", + current: { hitPoints: 0, sdc: 0, lifeState: "dead" }, }), ).toThrow(/coma\/death floor/i); }); + test("does not treat a root lifeState property as the persisted marker", () => { + const rootMarker = { + ...leyLineWalker, + rolled: { hitPoints: 18, sdc: 20 }, + current: { hitPoints: -14, sdc: 0 }, + lifeState: "dead", + }; + + expect(deriveSheet(rootMarker).vitals.lifeState).toBe("coma"); + }); + test("legacy marker-absent documents remain valid", () => { const sheet = deriveSheet({ ...leyLineWalker, From 6d7d3aca0cef81a9cf97ef1375b642a5b78b9951 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 20:01:47 +0800 Subject: [PATCH 11/25] feat(backend): enforce terminal life state --- packages/backend/convex/character_state.ts | 6 + packages/backend/convex/characters.ts | 71 +++++-- packages/backend/tests/characters.test.ts | 217 ++++++++++++++++++++- 3 files changed, 271 insertions(+), 23 deletions(-) diff --git a/packages/backend/convex/character_state.ts b/packages/backend/convex/character_state.ts index f848ca8..bd3a607 100644 --- a/packages/backend/convex/character_state.ts +++ b/packages/backend/convex/character_state.ts @@ -16,6 +16,12 @@ export async function loadCharacter(ctx: MutationCtx, id: Id<"characters">): Pro return characterSchema.parse(stored); } +export function requireLiving(character: Character, action: string): void { + if (character.current?.lifeState === "dead") { + throw new Error(`Life signs terminated — dead characters cannot ${action}.`); + } +} + export async function patchCurrent( ctx: MutationCtx, id: Id<"characters">, diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index 8a7302b..e93cd6d 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -1,8 +1,7 @@ import { - applyDamage as damagePools, + applyBodyDamage, armorMaxPool, armorNeedsRoll, - comaDeathFloor, damageArmor, deriveSheet, getItem, @@ -25,6 +24,7 @@ import { loadCharacter, patchCurrent, requireItemAt, + requireLiving, validateCharacter, } from "./character_state"; import { characterFields } from "./schema"; @@ -56,6 +56,8 @@ export const update = mutation({ args: { id: v.id("characters"), character: v.object(characterInputFields) }, returns: v.null(), handler: async (ctx, { id, character }) => { + const existing = await loadCharacter(ctx, id); + requireLiving(existing, "be updated"); await ctx.db.replace(id, validateCharacter(character)); return null; }, @@ -102,6 +104,7 @@ export const rollVitals = mutation({ }), handler: async (ctx, { id }) => { const character = await loadCharacter(ctx, id); + requireLiving(character, "roll vitals"); const occ = getOcc(character.occId); if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); const pe = character.attributes.PE; @@ -202,6 +205,7 @@ export const castSpell = mutation({ }), handler: async (ctx, { id, spellId, targetId, healPool }) => { const character = await loadCharacter(ctx, id); + requireLiving(character, "cast spells"); if (!character.spellIds.includes(spellId)) { throw new Error(`Character does not know the spell "${spellId}".`); } @@ -217,6 +221,11 @@ export const castSpell = mutation({ if (spell.healing?.othersOnly && !aimedAtOther) { throw new Error(`${spell.name} cannot be used on oneself.`); } + let healingTarget: Character | undefined; + if (spell.healing !== undefined && aimedAtOther) { + healingTarget = await loadCharacter(ctx, targetId!); + requireLiving(healingTarget, "receive magical healing"); + } const max = character.rolled?.ppe; if (max === undefined) throw new Error("Roll vitals before casting — no P.P.E. to spend."); const available = character.current?.ppe ?? max; @@ -266,7 +275,7 @@ export const castSpell = mutation({ } // Cross-document: spend on the caster, heal the target — one transaction, // the first table-shaped interaction (VTT groundwork). - const target = await loadCharacter(ctx, targetId); + const target = healingTarget!; const amounts = resolve(target); const { current: targetCurrent, gained } = healPools(target, amounts); await patchCurrent(ctx, id, character, spentCurrent); @@ -388,13 +397,25 @@ export const equipArmor = mutation({ */ export const applyDamage = mutation({ args: { id: v.id("characters"), amount: v.number(), toArmor: v.optional(v.boolean()) }, - returns: v.object({ - sdc: v.optional(v.number()), - hitPoints: v.optional(v.number()), - armor: v.optional(v.number()), - }), + returns: v.union( + v.object({ + sdc: v.number(), + hitPoints: v.number(), + before: v.object({ sdc: v.number(), hitPoints: v.number() }), + after: v.object({ sdc: v.number(), hitPoints: v.number() }), + amount: v.number(), + lifeState: v.union(v.literal("alive"), v.literal("coma"), v.literal("dead")), + }), + v.object({ + armor: v.number(), + amount: v.number(), + lifeState: v.union(v.literal("alive"), v.literal("coma"), v.literal("dead")), + }), + ), handler: async (ctx, { id, amount, toArmor }) => { const character = await loadCharacter(ctx, id); + requireLiving(character, "take damage"); + const sheet = deriveSheet(character); if (toArmor === true) { const worn = character.items.find((e) => e.worn === true); if (worn === undefined) throw new Error("No armor is worn — nothing to strike."); @@ -416,19 +437,28 @@ export const applyDamage = mutation({ // `damageArmor` rejects amounts that aren't whole, non-negative counts. const next = damageArmor(pool, amount); await patchCurrent(ctx, id, character, { ...character.current, armor: next }); - return { armor: next }; + return { armor: next, amount, lifeState: sheet.vitals.lifeState }; } - const rolled = character.rolled; - if (rolled?.hitPoints === undefined || rolled.sdc === undefined) { + const sdc = sheet.vitals.sdc.current; + const hitPoints = sheet.vitals.hitPoints.current; + if (sdc === undefined || hitPoints === undefined) { throw new Error("Roll vitals before applying damage — no pools to deplete."); } - const pool = { - sdc: character.current?.sdc ?? rolled.sdc, - hitPoints: character.current?.hitPoints ?? rolled.hitPoints, + const result = applyBodyDamage({ sdc, hitPoints }, amount, sheet.vitals.comaDeathFloor); + const current = { + ...character.current, + sdc: result.after.sdc, + hitPoints: result.after.hitPoints, + ...(result.lifeState === "dead" ? { lifeState: "dead" as const } : {}), + }; + await patchCurrent(ctx, id, character, current); + return { + ...result.after, + before: result.before, + after: result.after, + amount, + lifeState: result.lifeState, }; - const next = damagePools(pool, amount, comaDeathFloor(character.attributes.PE)); - await patchCurrent(ctx, id, character, { ...character.current, ...next }); - return next; }, }); @@ -447,6 +477,7 @@ export const heal = mutation({ returns: v.null(), handler: async (ctx, { id, ...amounts }) => { const character = await loadCharacter(ctx, id); + requireLiving(character, "receive healing"); const { current } = healPools(character, amounts); await patchCurrent(ctx, id, character, current); return null; @@ -473,6 +504,7 @@ export const rest = mutation({ }), handler: async (ctx, { id, hours, mode }) => { const character = await loadCharacter(ctx, id); + requireLiving(character, mode === "meditation" ? "meditate" : "rest"); const occ = getOcc(character.occId); if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); const max = character.rolled?.ppe; @@ -503,6 +535,7 @@ export const leyLineDraw = mutation({ }), handler: async (ctx, { id, melees, atNexus }) => { const character = await loadCharacter(ctx, id); + requireLiving(character, "draw from a ley line"); const occ = getOcc(character.occId); if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); if (occ.ppe === undefined) { @@ -545,6 +578,7 @@ export const treat = mutation({ }), handler: async (ctx, { id, professional, day }) => { const character = await loadCharacter(ctx, id); + requireLiving(character, "receive treatment"); const rolled = character.rolled; if (rolled?.hitPoints === undefined || rolled.sdc === undefined) { throw new Error("Roll vitals before treatment — no pools to recover."); @@ -574,7 +608,8 @@ export const restoreVitals = mutation({ args: { id: v.id("characters") }, returns: v.null(), handler: async (ctx, { id }) => { - await loadCharacter(ctx, id); // existence check + const character = await loadCharacter(ctx, id); + requireLiving(character, "restore vitals"); await ctx.db.patch(id, { current: undefined }); return null; }, diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 7556886..83863e9 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -200,6 +200,29 @@ describe("living vitals — current vs. max (#38)", () => { return id; } + async function markDead( + t: ReturnType, + id: Awaited>, + ) { + await t.run(async (ctx) => { + await ctx.db.patch(id, { + current: { sdc: 0, hitPoints: -14, lifeState: "dead" }, + }); + }); + } + + async function savedHealingVesper(t: ReturnType, name: string) { + const id = await t.mutation(api.characters.create, { + ...vesper, + name, + spellIds: [...vesper.spellIds, "heal-wounds"], + }); + await t.run(async (ctx) => { + await ctx.db.patch(id, { rolled: { hitPoints: 18, sdc: 20, ppe: 84 } }); + }); + return id; + } + test("castSpell spends the spell's printed cost, floor at the server", async () => { const t = convexTest(schema, modules); const id = await savedVesper(t); @@ -246,25 +269,209 @@ describe("living vitals — current vs. max (#38)", () => { const id = await savedVesper(t); expect(await t.mutation(api.characters.applyDamage, { id, amount: 7 })).toEqual({ + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 13, hitPoints: 18 }, + amount: 7, + lifeState: "alive", sdc: 13, hitPoints: 18, }); expect(await t.mutation(api.characters.applyDamage, { id, amount: 20 })).toEqual({ + before: { sdc: 13, hitPoints: 18 }, + after: { sdc: 0, hitPoints: 11 }, + amount: 20, + lifeState: "alive", sdc: 0, hitPoints: 11, }); + await expect(t.mutation(api.characters.applyDamage, { id, amount: -5 })).rejects.toThrow( + /non-negative/, + ); // Overkill clamps at -(P.E.), the coma/death floor. expect(await t.mutation(api.characters.applyDamage, { id, amount: 999 })).toEqual({ + before: { sdc: 0, hitPoints: 11 }, + after: { sdc: 0, hitPoints: -14 }, + amount: 999, + lifeState: "dead", sdc: 0, hitPoints: -14, }); const sheet = await t.query(api.characters.sheet, { id }); expect(sheet?.vitals.hitPoints).toMatchObject({ rolled: 18, current: -14 }); expect(sheet?.vitals.sdc).toMatchObject({ rolled: 20, current: 0 }); + expect(sheet?.vitals.lifeState).toBe("dead"); + }); - await expect(t.mutation(api.characters.applyDamage, { id, amount: -5 })).rejects.toThrow( - /non-negative/, - ); + test.each([ + { amount: 11, lifeState: "coma" as const, marker: undefined }, + { amount: 12, lifeState: "dead" as const, marker: "dead" as const }, + ])( + "manual damage at the P.E. floor stores $lifeState without raw overflow", + async ({ amount, lifeState, marker }) => { + const t = convexTest(schema, modules); + const id = await t.mutation(api.characters.create, { + ...vesper, + attributes: { ...vesper.attributes, PE: 10 }, + rolled: { hitPoints: 11, sdc: 20, ppe: 84 }, + current: { sdc: 0, hitPoints: 1 }, + }); + + expect(await t.mutation(api.characters.applyDamage, { id, amount })).toEqual({ + before: { sdc: 0, hitPoints: 1 }, + after: { sdc: 0, hitPoints: -10 }, + amount, + lifeState, + sdc: 0, + hitPoints: -10, + }); + expect((await t.query(api.characters.get, { id }))?.current).toEqual({ + sdc: 0, + hitPoints: -10, + ...(marker === undefined ? {} : { lifeState: marker }), + }); + }, + ); + + const terminalActions = [ + { + name: "full update", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.update, { + id, + character: { + ...vesper, + name: "Resurrected by replacement", + attributes: { ...vesper.attributes, PE: -3 }, + }, + }), + }, + { + name: "vitals roll", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.rollVitals, { id }), + }, + { + name: "vitals restore", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.restoreVitals, { id }), + }, + { + name: "further damage", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.applyDamage, { id, amount: 1 }), + }, + { + name: "manual healing", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.heal, { id, hitPoints: 1 }), + }, + { + name: "rest", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.rest, { id, hours: 1, mode: "rest" }), + }, + { + name: "meditation", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.rest, { id, hours: 1, mode: "meditation" }), + }, + { + name: "treatment", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.treat, { id, professional: true }), + }, + { + name: "ley draw", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.leyLineDraw, { id, melees: 1, atNexus: false }), + }, + { + name: "spell casting", + run: (t: ReturnType, id: Awaited>) => + t.mutation(api.characters.castSpell, { id, spellId: "energy-bolt" }), + }, + ]; + + test.each(terminalActions)( + "dead characters reject $name without state change", + async ({ run }) => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await markDead(t, id); + const before = await t.query(api.characters.get, { id }); + + await expect(run(t, id)).rejects.toThrow(/Life signs terminated/); + expect(await t.query(api.characters.get, { id })).toEqual(before); + }, + ); + + test("cross-character healing guards both a dead caster and a dead target atomically", async () => { + const t = convexTest(schema, modules); + const deadCaster = await savedHealingVesper(t, "Dead caster"); + const livingTarget = await savedHealingVesper(t, "Living target"); + await markDead(t, deadCaster); + const deadCasterBefore = await t.query(api.characters.get, { id: deadCaster }); + const livingTargetBefore = await t.query(api.characters.get, { id: livingTarget }); + + await expect( + t.mutation(api.characters.castSpell, { + id: deadCaster, + spellId: "heal-wounds", + targetId: livingTarget, + }), + ).rejects.toThrow(/Life signs terminated/); + expect(await t.query(api.characters.get, { id: deadCaster })).toEqual(deadCasterBefore); + expect(await t.query(api.characters.get, { id: livingTarget })).toEqual(livingTargetBefore); + + const livingCaster = await savedHealingVesper(t, "Living caster"); + const deadTarget = await savedHealingVesper(t, "Dead target"); + await markDead(t, deadTarget); + const livingCasterBefore = await t.query(api.characters.get, { id: livingCaster }); + const deadTargetBefore = await t.query(api.characters.get, { id: deadTarget }); + + await expect( + t.mutation(api.characters.castSpell, { + id: livingCaster, + spellId: "heal-wounds", + targetId: deadTarget, + }), + ).rejects.toThrow(/Life signs terminated/); + expect(await t.query(api.characters.get, { id: livingCaster })).toEqual(livingCasterBefore); + expect(await t.query(api.characters.get, { id: deadTarget })).toEqual(deadTargetBefore); + }); + + test("dead characters may edit narrative and manage inventory without losing the marker", async () => { + const t = convexTest(schema, modules); + const id = await savedVesper(t); + await markDead(t, id); + + await t.mutation(api.characters.updateNarrative, { + id, + narrative: { epithet: "Remembered at the ley line." }, + }); + expect((await t.query(api.characters.get, { id }))?.current?.lifeState).toBe("dead"); + + expect(await t.mutation(api.characters.addItem, { id, itemId: "gladiator" })).toEqual({ + index: 0, + }); + expect((await t.query(api.characters.get, { id }))?.current?.lifeState).toBe("dead"); + + await t.mutation(api.characters.equipArmor, { + id, + index: 0, + expect: { itemId: "gladiator" }, + }); + expect((await t.query(api.characters.get, { id }))?.current?.lifeState).toBe("dead"); + + await t.mutation(api.characters.removeItem, { + id, + index: 0, + expect: { itemId: "gladiator", worn: true }, + }); + const stored = await t.query(api.characters.get, { id }); + expect(stored?.narrative?.epithet).toBe("Remembered at the ley line."); + expect(stored?.items).toEqual([]); + expect(stored?.current).toEqual({ sdc: 0, hitPoints: -14, lifeState: "dead" }); }); test("heal recovers points, clamped at the rolled maximums", async () => { @@ -562,7 +769,7 @@ describe("living vitals — current vs. max (#38)", () => { // "Subtract the damage from the armor's S.D.C." (RUE p.287). expect(await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true })).toEqual( - { armor: 40 }, + { armor: 40, amount: 30, lifeState: "alive" }, ); await expect( t.mutation(api.characters.applyDamage, { id, amount: -5, toArmor: true }), @@ -570,7 +777,7 @@ describe("living vitals — current vs. max (#38)", () => { // The depleting hit is fully absorbed — the body pools stay untouched // (only FUTURE attacks reach the body once the suit reads 0). expect(await t.mutation(api.characters.applyDamage, { id, amount: 99, toArmor: true })).toEqual( - { armor: 0 }, + { armor: 0, amount: 99, lifeState: "alive" }, ); const sheet = await t.query(api.characters.sheet, { id }); expect(sheet?.armor).toMatchObject({ max: 70, current: 0 }); From f066bcc834c3f97d58a2b6ce6c2527e8bd4716ca Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 20:19:20 +0800 Subject: [PATCH 12/25] feat(backend): declare mega-damage attacks --- packages/backend/convex/combat.ts | 35 ++-- packages/backend/tests/combat.test.ts | 266 ++++++++++++++++++++------ 2 files changed, 233 insertions(+), 68 deletions(-) diff --git a/packages/backend/convex/combat.ts b/packages/backend/convex/combat.ts index 3d373fe..535400c 100644 --- a/packages/backend/convex/combat.ts +++ b/packages/backend/convex/combat.ts @@ -196,7 +196,17 @@ export const declareAttack = mutation({ const attackerSheet = deriveSheet(attacker); const defenderSheet = deriveSheet(defender); + if (attackerSheet.vitals.lifeState === "dead" || defenderSheet.vitals.lifeState === "dead") { + combatFailure("combatantDead", "Dead combatants cannot enter combat."); + } requireCombatReady(attackerSheet, "attackerNotReady"); + const protection = deriveProtection(defenderSheet); + if ( + protection.kind === "mdcArmor" && + (protection.max === undefined || protection.current === undefined) + ) { + combatFailure("armorNotReady", "Roll the worn M.D.C. armor capacity before entering combat."); + } requireCombatReady(defenderSheet, "defenderNotReady"); try { @@ -215,13 +225,6 @@ export const declareAttack = mutation({ ); } const context = parseDeclaredContext(attack, args.context); - const protection = deriveProtection(defenderSheet); - if (protection.kind === "mdcArmor") { - combatFailure( - "unsupportedMdcProtection", - "M.D.C. protection requires the full M.D.C. combat follow-up.", - ); - } const defenseOptions = deriveDefenseOptions(defenderSheet, attack, context); const attackerStateToken = attackerCombatStateToken(attackerSheet, args.weaponIndex); @@ -407,16 +410,22 @@ export const targets = query({ const ready = sheet.vitals.sdc.rolled !== undefined && sheet.vitals.hitPoints.rolled !== undefined; const protection = deriveProtection(sheet); + const disabledReason = + sheet.vitals.lifeState === "dead" + ? ("combatantDead" as const) + : protection.kind === "mdcArmor" && + (protection.max === undefined || protection.current === undefined) + ? ("armorNotReady" as const) + : !ready + ? ("defenderNotReady" as const) + : undefined; return { id: doc._id, name: sheet.name, ready, - protection: protection.kind, - ...(ready - ? protection.kind === "mdcArmor" - ? { disabledReason: "unsupportedMdcProtection" as const } - : {} - : { disabledReason: "defenderNotReady" as const }), + lifeState: sheet.vitals.lifeState, + protection, + ...(disabledReason === undefined ? {} : { disabledReason }), }; }); }, diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index 5a2facd..0af4f60 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -383,7 +383,7 @@ describe("combat persistence compatibility", () => { }); describe("combat target selector", () => { - test("excludes self, stays bounded, and safely classifies readiness and protection", async () => { + test("excludes self, stays bounded, and exposes exact combat readiness and protection", async () => { const t = convexTest(schema, modules); for (let index = 0; index < 52; index += 1) { @@ -393,23 +393,40 @@ describe("combat target selector", () => { rolled: { hitPoints: 18, sdc: 20 }, }); } - const unreadyId = await t.mutation(api.characters.create, { + const sdcId = await t.mutation(api.characters.create, { ...character, - name: "Unready", + name: "S.D.C. target", + rolled: ready, }); - const armoredId = await t.mutation(api.characters.create, { + const unreadyBodyId = await t.mutation(api.characters.create, { ...character, - name: "Armored", - rolled: { hitPoints: 18, sdc: 20 }, - items: [{ itemId: "gladiator", worn: true }], + name: "Unready body", }); - const depletedId = await t.mutation(api.characters.create, { + const intactMdcId = await t.mutation(api.characters.create, { ...character, - name: "Depleted", - rolled: { hitPoints: 18, sdc: 20 }, - items: [{ itemId: "gladiator", worn: true }], + name: "Intact M.D.C.", + rolled: ready, + items: [{ itemId: "llw-concealed-light", worn: true, rolledMdc: 39 }], + }); + const depletedMdcId = await t.mutation(api.characters.create, { + ...character, + name: "Depleted M.D.C.", + rolled: ready, + items: [{ itemId: "llw-concealed-light", worn: true, rolledMdc: 39 }], current: { armor: 0 }, }); + const unreadyArmorId = await t.mutation(api.characters.create, { + ...character, + name: "Unready armor", + items: [{ itemId: "llw-concealed-light", worn: true }], + }); + const deadId = await t.mutation(api.characters.create, { + ...character, + name: "Dead", + rolled: ready, + items: [{ itemId: "llw-concealed-light", worn: true }], + current: { hitPoints: -14, sdc: 0, lifeState: "dead" }, + }); const attackerId = await t.mutation(api.characters.create, { ...character, name: "Attacker", @@ -421,24 +438,69 @@ describe("combat target selector", () => { expect(targets).toHaveLength(49); expect(targets).not.toContainEqual(expect.objectContaining({ id: attackerId })); expect(targets).toContainEqual({ - id: unreadyId, - name: "Unready", + id: sdcId, + name: "S.D.C. target", + ready: true, + lifeState: "alive", + protection: { kind: "none" }, + }); + expect(targets).toContainEqual({ + id: unreadyBodyId, + name: "Unready body", ready: false, - protection: "none", + lifeState: "alive", + protection: { kind: "none" }, disabledReason: "defenderNotReady", }); expect(targets).toContainEqual({ - id: armoredId, - name: "Armored", + id: intactMdcId, + name: "Intact M.D.C.", + ready: true, + lifeState: "alive", + protection: { + kind: "mdcArmor", + itemId: "llw-concealed-light", + name: "Ley Line Walker Concealed Armor (Light)", + max: 39, + current: 39, + }, + }); + expect(targets).toContainEqual({ + id: depletedMdcId, + name: "Depleted M.D.C.", ready: true, - protection: "mdcArmor", - disabledReason: "unsupportedMdcProtection", + lifeState: "alive", + protection: { + kind: "mdcArmor", + itemId: "llw-concealed-light", + name: "Ley Line Walker Concealed Armor (Light)", + max: 39, + current: 0, + }, }); expect(targets).toContainEqual({ - id: depletedId, - name: "Depleted", + id: unreadyArmorId, + name: "Unready armor", + ready: false, + lifeState: "alive", + protection: { + kind: "mdcArmor", + itemId: "llw-concealed-light", + name: "Ley Line Walker Concealed Armor (Light)", + }, + disabledReason: "armorNotReady", + }); + expect(targets).toContainEqual({ + id: deadId, + name: "Dead", ready: true, - protection: "none", + lifeState: "dead", + protection: { + kind: "mdcArmor", + itemId: "llw-concealed-light", + name: "Ley Line Walker Concealed Armor (Light)", + }, + disabledReason: "combatantDead", }); }); }); @@ -598,36 +660,127 @@ describe("combat attack declaration", () => { expect(await exchangeCount(t)).toBe(0); }); - test("refuses M.D. weapons before any exchange is inserted", async () => { - const t = testDb(); - const attackerId = await createCharacter(t, { - rolled: ready, - items: [{ itemId: "wilks-320-laser-pistol" }], - }); - const defenderId = await createCharacter(t, { rolled: ready }); - const random = vi.spyOn(Math, "random"); - - await expectCombatFailure( - t.mutation(api.combat.declareAttack, { + test.each([ + ["energy pistol", "wilks-320-laser-pistol", "energyPistol", "1D6"], + ["energy rifle", "wilks-447-laser-rifle", "energyRifle", "3D6"], + ] as const)( + "persists a legal M.D. $0 declaration after exactly one strike roll", + async (_label, itemId, category, damageFormula) => { + const t = testDb(); + const attacker: Character = { + ...character, + name: "M.D. attacker", + rolled: ready, + items: [{ itemId }], + }; + const defender: Character = { ...character, name: "M.D. defender", rolled: ready }; + const attackerId = await createCharacter(t, attacker); + const defenderId = await createCharacter(t, defender); + const random = vi.spyOn(Math, "random").mockReturnValue(0.5); + + const exchange = await t.mutation(api.combat.declareAttack, { attackerId, defenderId, weaponIndex: 0, - expect: { itemId: "wilks-320-laser-pistol" }, + expect: { itemId }, context: { kind: "ranged", defenderAware: true, rangeBand: "normal", }, - }), - "unsupportedMdWeapon", - "M.D. weapons require the full M.D.C. combat follow-up.", - ); - expect(random).not.toHaveBeenCalled(); - random.mockRestore(); - expect(await exchangeCount(t)).toBe(0); + }); + + expect(random).toHaveBeenCalledTimes(1); + random.mockRestore(); + expect(exchange).toMatchObject({ + attackerId, + defenderId, + weapon: { index: 0, itemId, category }, + attack: { kind: "ranged", damageFormula, damageType: "md" }, + attackerStateToken: attackerCombatStateToken(deriveSheet(attacker), 0), + defenderStateToken: defenderCombatStateToken(deriveSheet(defender)), + strikeRoll: { die: 11, target: 8 }, + status: "pendingDefense", + }); + expect(await exchangeCount(t)).toBe(1); + }, + ); + + test("allows intact and depleted M.D.C. protection at declaration", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "survival-knife" }], + }); + const intactDefenderId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "gladiator", worn: true }], + }); + const depletedDefenderId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "gladiator", worn: true }], + current: { armor: 0 }, + }); + const random = vi.spyOn(Math, "random").mockReturnValue(0.5); + + try { + for (const defenderId of [intactDefenderId, depletedDefenderId]) { + await t.mutation(api.combat.declareAttack, { + attackerId, + defenderId, + weaponIndex: 0, + expect: { itemId: "survival-knife" }, + context: meleeContext, + }); + } + + expect(random).toHaveBeenCalledTimes(2); + } finally { + random.mockRestore(); + } + expect(await exchangeCount(t)).toBe(2); }); - test("refuses nondepleted M.D.C. protection before any exchange is inserted", async () => { + test.each(["attacker", "defender"] as const)( + "rejects a dead %s before dice or insertion", + async (deadRole) => { + const t = testDb(); + const liveAttackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "survival-knife" }], + }); + const deadId = await createCharacter(t, { + rolled: ready, + items: + deadRole === "attacker" + ? [{ itemId: "survival-knife" }] + : [{ itemId: "llw-concealed-light", worn: true }], + current: { hitPoints: -14, sdc: 0, lifeState: "dead" }, + }); + const liveDefenderId = await createCharacter(t, { rolled: ready }); + const random = vi.spyOn(Math, "random"); + + try { + await expectCombatFailure( + t.mutation(api.combat.declareAttack, { + attackerId: deadRole === "attacker" ? deadId : liveAttackerId, + defenderId: deadRole === "defender" ? deadId : liveDefenderId, + weaponIndex: 0, + expect: { itemId: "survival-knife" }, + context: meleeContext, + }), + "combatantDead", + "Dead combatants cannot enter combat.", + ); + expect(random).not.toHaveBeenCalled(); + } finally { + random.mockRestore(); + } + expect(await exchangeCount(t)).toBe(0); + }, + ); + + test("rejects missing M.D.C. armor capacity before dice or insertion", async () => { const t = testDb(); const attackerId = await createCharacter(t, { rolled: ready, @@ -635,23 +788,26 @@ describe("combat attack declaration", () => { }); const defenderId = await createCharacter(t, { rolled: ready, - items: [{ itemId: "gladiator", worn: true }], + items: [{ itemId: "llw-concealed-light", worn: true }], }); const random = vi.spyOn(Math, "random"); - await expectCombatFailure( - t.mutation(api.combat.declareAttack, { - attackerId, - defenderId, - weaponIndex: 0, - expect: { itemId: "survival-knife" }, - context: meleeContext, - }), - "unsupportedMdcProtection", - "M.D.C. protection requires the full M.D.C. combat follow-up.", - ); - expect(random).not.toHaveBeenCalled(); - random.mockRestore(); + try { + await expectCombatFailure( + t.mutation(api.combat.declareAttack, { + attackerId, + defenderId, + weaponIndex: 0, + expect: { itemId: "survival-knife" }, + context: meleeContext, + }), + "armorNotReady", + "Roll the worn M.D.C. armor capacity before entering combat.", + ); + expect(random).not.toHaveBeenCalled(); + } finally { + random.mockRestore(); + } expect(await exchangeCount(t)).toBe(0); }); From d235426bad3c4d3e73a3798c50c2d5c8bbd57be6 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 20:29:14 +0800 Subject: [PATCH 13/25] fix(web): map combat target readiness --- apps/web/src/lib/combat-exchange.ts | 13 +++-- apps/web/tests/combat-exchange.test.ts | 72 ++++++++++++++++++++------ 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/apps/web/src/lib/combat-exchange.ts b/apps/web/src/lib/combat-exchange.ts index 91c645e..422a8ba 100644 --- a/apps/web/src/lib/combat-exchange.ts +++ b/apps/web/src/lib/combat-exchange.ts @@ -54,9 +54,16 @@ export function combatWeaponChoices(sheet: CharacterSheet): Array<{ } export function combatTargetDisabledReason(target: CombatTargetSummary): string | undefined { - if (!target.ready) return "Roll this target's H.P. and S.D.C. first."; - if (target.protection === "mdcArmor") return "Full M.D.C. combat is follow-up work."; - return undefined; + switch (target.disabledReason) { + case "defenderNotReady": + return "Roll this target's H.P. and S.D.C. first."; + case "armorNotReady": + return "Roll this target's worn armor M.D.C. first."; + case "combatantDead": + return "Life signs terminated; this target cannot enter combat."; + default: + return undefined; + } } export function exchangeTone(exchange: ExchangeSummary): "dim" | "warn" | "bad" | "good" { diff --git a/apps/web/tests/combat-exchange.test.ts b/apps/web/tests/combat-exchange.test.ts index 847922f..a75bea6 100644 --- a/apps/web/tests/combat-exchange.test.ts +++ b/apps/web/tests/combat-exchange.test.ts @@ -270,31 +270,71 @@ describe("combat weapon choices", () => { describe("combat target choices", () => { const targetId = "target-1" as CombatTargetSummary["id"]; - test("explains unready and M.D.C.-protected targets precisely", () => { - expect( - combatTargetDisabledReason({ + test.each([ + [ + "unrolled body", + { id: targetId, - name: "Unready", + name: "Unrolled body", ready: false, - protection: "none", + lifeState: "alive", + protection: { kind: "none" }, disabledReason: "defenderNotReady", - }), - ).toBe("Roll this target's H.P. and S.D.C. first."); - expect( - combatTargetDisabledReason({ + }, + "Roll this target's H.P. and S.D.C. first.", + ], + [ + "unrolled armor", + { id: targetId, - name: "M.D.C. Target", + name: "Unrolled armor", + ready: false, + lifeState: "alive", + protection: { + kind: "mdcArmor", + itemId: "llw-concealed-light", + name: "Ley Line Walker Concealed Armor (Light)", + }, + disabledReason: "armorNotReady", + }, + "Roll this target's worn armor M.D.C. first.", + ], + [ + "dead combatant", + { + id: targetId, + name: "Dead combatant", ready: true, - protection: "mdcArmor", - disabledReason: "unsupportedMdcProtection", - }), - ).toBe("Full M.D.C. combat is follow-up work."); + lifeState: "dead", + protection: { kind: "none" }, + disabledReason: "combatantDead", + }, + "Life signs terminated; this target cannot enter combat.", + ], + ] satisfies ReadonlyArray)( + "maps the server's $0 reason to exact disabled copy", + (_label, target, expected) => { + expect(combatTargetDisabledReason(target)).toBe(expected); + }, + ); + + test.each([ + ["intact", 39], + ["depleted", 0], + ] as const)("keeps $0 M.D.C. protection enabled", (_label, current) => { expect( combatTargetDisabledReason({ id: targetId, - name: "Ready", + name: "M.D.C. target", ready: true, - protection: "sdcArmor", + lifeState: "alive", + protection: { + kind: "mdcArmor", + itemId: "llw-concealed-light", + name: "Ley Line Walker Concealed Armor (Light)", + max: 39, + current, + }, }), ).toBeUndefined(); }); From af3f45ebe13f2f42f5490604d294c6301bfeb433 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 20:46:34 +0800 Subject: [PATCH 14/25] feat(backend): persist tiered combat outcomes --- packages/backend/convex/combat.ts | 43 +- packages/backend/tests/combat.test.ts | 646 ++++++++++++++++++++++++++ 2 files changed, 675 insertions(+), 14 deletions(-) diff --git a/packages/backend/convex/combat.ts b/packages/backend/convex/combat.ts index 535400c..929e308 100644 --- a/packages/backend/convex/combat.ts +++ b/packages/backend/convex/combat.ts @@ -314,9 +314,6 @@ export const respondToAttack = mutation({ return finalizeStale(ctx, pending); } const protection = deriveProtection(defenderSheet); - if (protection.kind === "mdcArmor") { - return finalizeStale(ctx, pending); - } let options: DefenseOption[]; try { options = deriveDefenseOptions(defenderSheet, attack, context); @@ -342,13 +339,14 @@ export const respondToAttack = mutation({ ? {} : { defense: { kind: response.kind as DefenseKind, roll: defenseRoll } }), allowedDefenses: defenseRoll === undefined ? [] : [response.kind as DefenseKind], - damageType: "sdc", + damageType: stored.attack.damageType, criticalOn: attack.criticalOn, }); const damageRoll = opposed.outcome === "hit" ? rollDamage(attack.damageFormula, attack.damageBonus) : undefined; + const attackForResolution = { ...attack, damageType: stored.attack.damageType }; const resolution = resolveCombatExchange({ - attack, + attack: attackForResolution, context, strikeRoll: stored.strikeRoll, response, @@ -363,15 +361,32 @@ export const respondToAttack = mutation({ }); if (resolution.outcome === "hit") { - const current = - resolution.route.kind === "armor" - ? { ...defender.current, armor: resolution.route.armor.after } - : { - ...defender.current, - sdc: resolution.route.body.after.sdc, - hitPoints: resolution.route.body.after.hitPoints, - }; - await patchCurrent(ctx, stored.defenderId, defender, current); + let current: Character["current"] | undefined; + switch (resolution.route.kind) { + case "stopped": + break; + case "armor": + current = { ...defender.current, armor: resolution.route.armor.after }; + break; + case "body": + current = { + ...defender.current, + sdc: resolution.route.body.after.sdc, + hitPoints: resolution.route.body.after.hitPoints, + }; + break; + case "fatal": + current = { + ...defender.current, + sdc: resolution.route.body.after.sdc, + hitPoints: resolution.route.body.after.hitPoints, + lifeState: "dead", + }; + break; + } + if (current !== undefined) { + await patchCurrent(ctx, stored.defenderId, defender, current); + } } await ctx.db.replace(pending.id, { ...pending.base, status: "resolved", resolution }); return (await ctx.db.get(pending.id))!; diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index 0af4f60..55d2d19 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -1,8 +1,11 @@ import { attackerCombatStateToken, defenderCombatStateToken, + deriveAttackProfile, deriveSheet, + itemCatalog, type Character, + type CombatContext, type CombatExchangeErrorCode, } from "@riftforge/rules"; import { convexTest } from "convex-test"; @@ -80,6 +83,82 @@ async function declarePending( throw new Error("Could not obtain a pending combat declaration."); } +async function declarePendingAtTotal( + t: TestDb, + attackerId: Id<"characters">, + defenderId: Id<"characters">, + weaponItemId: string, + strikeTotal: number, + context: CombatContext, +) { + const attacker = await getCharacter(t, attackerId); + if (attacker === null) throw new Error("Expected the attacker to exist."); + const attack = deriveAttackProfile(deriveSheet(attacker), 0); + if (!attack.supported) throw new Error("Expected a supported attack fixture."); + const strikeModifier = strikeTotal - attack.strikeBonus - 10; + const random = vi.spyOn(Math, "random").mockReturnValue(0.475); + try { + const exchange = await t.mutation(api.combat.declareAttack, { + attackerId, + defenderId, + weaponIndex: 0, + expect: { itemId: weaponItemId }, + context: { + ...context, + ...(strikeModifier === 0 + ? {} + : { + strikeModifier, + strikeModifierReason: "Exact integration-test strike total", + }), + }, + }); + if (exchange.status !== "pendingDefense") { + throw new Error(`Expected a pending declaration at strike total ${strikeTotal}.`); + } + expect(exchange.strikeRoll).toMatchObject({ die: 10, total: strikeTotal }); + return exchange; + } finally { + random.mockRestore(); + } +} + +async function respondWithDamage( + t: TestDb, + exchangeId: Id<"combatExchanges">, + die: number, + sides: number, +) { + const random = vi.spyOn(Math, "random").mockReturnValue((die - 0.5) / sides); + try { + return await t.mutation(api.combat.respondToAttack, { + exchangeId, + response: { kind: "none" }, + }); + } finally { + random.mockRestore(); + } +} + +async function withCatalogFixture( + itemId: string, + configure: (item: Record) => void, + run: () => Promise, +): Promise { + const item = itemCatalog.items.find((candidate) => candidate.id === itemId); + if (item === undefined) throw new Error(`Missing catalog fixture ${itemId}.`); + const mutable = item as unknown as Record; + const original = structuredClone(mutable); + try { + configure(mutable); + return await run(); + } finally { + for (const key of Object.keys(mutable)) delete mutable[key]; + Object.assign(mutable, original); + expect(mutable).toEqual(original); + } +} + async function expectCombatFailure( promise: Promise, code: CombatExchangeErrorCode, @@ -1024,6 +1103,417 @@ describe("combat attack declaration", () => { }); }); +describe("atomic tiered combat response persistence", () => { + test.each([ + [99, "stopped", 10], + [100, "armor", 9], + ] as const)( + "routes %i S.D.C. atomically against intact M.D.C. armor as %s", + async (damage, expectedKind, expectedArmor) => + withCatalogFixture( + "survival-knife", + (item) => { + const priorDamage = item.damage as Record; + item.damage = { ...priorDamage, formula: "1D100", type: "sdc" }; + }, + async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + attributes: { ...character.attributes, PS: 10 }, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "gladiator", worn: true }], + current: { armor: 10 }, + }); + const pending = await declarePendingAtTotal( + t, + attackerId, + defenderId, + "survival-knife", + 10, + meleeContext, + ); + + const resolved = await respondWithDamage(t, pending._id, damage, 100); + + if (resolved.status !== "resolved" || resolved.resolution.outcome !== "hit") { + throw new Error("Expected an intact-armor hit to resolve."); + } + expect(resolved.resolution.totalDamage).toBe(damage); + expect(resolved.resolution.route).toEqual( + expectedKind === "stopped" + ? { + routingVersion: 2, + kind: "stopped", + reason: "intactMdcImpervious", + nativeDamage: { type: "sdc", value: damage }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 10, + after: 10, + }, + body: { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, + }, + } + : { + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "sdc", value: damage }, + convertedDamage: { type: "md", value: 1 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 10, + after: 9, + }, + body: { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, + }, + finalBlastAbsorbed: false, + }, + ); + expect((await getCharacter(t, defenderId))?.current).toEqual({ + armor: expectedArmor, + }); + }, + ), + ); + + test("an armor-destroying M.D. blast patches armor only", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "wilks-320-laser-pistol" }], + }); + const defenderId = await createCharacter(t, { + rolled: { ...ready, ppe: 100 }, + items: [{ itemId: "gladiator", worn: true }], + current: { armor: 1, sdc: 17, hitPoints: 16, ppe: 79 }, + }); + const pending = await declarePendingAtTotal( + t, + attackerId, + defenderId, + "wilks-320-laser-pistol", + 12, + { kind: "ranged", defenderAware: true, rangeBand: "normal" }, + ); + + const resolved = await respondWithDamage(t, pending._id, 6, 6); + + if (resolved.status !== "resolved" || resolved.resolution.outcome !== "hit") { + throw new Error("Expected the destroying M.D. blast to resolve."); + } + expect(resolved.resolution.totalDamage).toBe(6); + expect(resolved.resolution.route).toEqual({ + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "md", value: 6 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 1, + after: 0, + }, + body: { + before: { sdc: 17, hitPoints: 16 }, + after: { sdc: 17, hitPoints: 16 }, + }, + finalBlastAbsorbed: true, + }); + expect((await getCharacter(t, defenderId))?.current).toEqual({ + armor: 0, + sdc: 17, + hitPoints: 16, + ppe: 79, + }); + }); + + test.each([ + [7, "stopped", 20], + [8, "body", 15], + ] as const)( + "routes an S.D.C. strike total of %i against a depleted M.D.C. shell as %s", + async (strikeTotal, expectedKind, expectedSdc) => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + attributes: { ...character.attributes, PS: 10 }, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "gladiator", worn: true }], + current: { armor: 0 }, + }); + const pending = await declarePendingAtTotal( + t, + attackerId, + defenderId, + "survival-knife", + strikeTotal, + meleeContext, + ); + + const resolved = await respondWithDamage(t, pending._id, 5, 6); + + if (resolved.status !== "resolved" || resolved.resolution.outcome !== "hit") { + throw new Error("Expected the depleted-shell S.D.C. hit to resolve."); + } + expect(resolved.resolution.totalDamage).toBe(5); + expect(resolved.resolution.route).toEqual( + expectedKind === "stopped" + ? { + routingVersion: 2, + kind: "stopped", + reason: "depletedMdcShell", + nativeDamage: { type: "sdc", value: 5 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 0, + after: 0, + }, + body: { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, + }, + } + : { + routingVersion: 2, + kind: "body", + nativeDamage: { type: "sdc", value: 5 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 0, + after: 0, + }, + body: { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 15, hitPoints: 18 }, + }, + lifeState: { before: "alive", after: "alive" }, + }, + ); + expect((await getCharacter(t, defenderId))?.current).toEqual( + expectedKind === "stopped" ? { armor: 0 } : { armor: 0, sdc: expectedSdc, hitPoints: 18 }, + ); + }, + ); + + test("converts M.D. into body damage through a depleted M.D.C. shell", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "wilks-320-laser-pistol" }], + }); + const defenderId = await createCharacter(t, { + rolled: { hitPoints: 18, sdc: 120 }, + items: [{ itemId: "gladiator", worn: true }], + current: { armor: 0 }, + }); + const pending = await declarePendingAtTotal( + t, + attackerId, + defenderId, + "wilks-320-laser-pistol", + 8, + { kind: "ranged", defenderAware: true, rangeBand: "normal" }, + ); + + const resolved = await respondWithDamage(t, pending._id, 1, 6); + + if (resolved.status !== "resolved" || resolved.resolution.outcome !== "hit") { + throw new Error("Expected M.D. to pass through the depleted shell."); + } + expect(resolved.resolution.totalDamage).toBe(1); + expect(resolved.resolution.route).toEqual({ + routingVersion: 2, + kind: "body", + nativeDamage: { type: "md", value: 1 }, + convertedDamage: { type: "sdc", value: 100 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 0, + after: 0, + }, + body: { + before: { sdc: 120, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, + }, + lifeState: { before: "alive", after: "alive" }, + }); + expect((await getCharacter(t, defenderId))?.current).toEqual({ + armor: 0, + sdc: 20, + hitPoints: 18, + }); + }); + + test.each([ + [12, "armor", 0, 120], + [13, "body", 10, 20], + ] as const)( + "routes M.D. at strike total %i against S.D.C. armor by A.R. as %s", + async (strikeTotal, expectedKind, expectedArmor, expectedSdc) => + withCatalogFixture( + "gladiator", + (item) => { + delete item.mdc; + item.ar = 12; + item.sdc = 50; + }, + async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "wilks-320-laser-pistol" }], + }); + const defenderId = await createCharacter(t, { + rolled: { hitPoints: 18, sdc: 120 }, + items: [{ itemId: "gladiator", worn: true }], + current: { armor: 10 }, + }); + const pending = await declarePendingAtTotal( + t, + attackerId, + defenderId, + "wilks-320-laser-pistol", + strikeTotal, + { kind: "ranged", defenderAware: true, rangeBand: "normal" }, + ); + + const resolved = await respondWithDamage(t, pending._id, 1, 6); + + if (resolved.status !== "resolved" || resolved.resolution.outcome !== "hit") { + throw new Error("Expected the M.D. attack against S.D.C. armor to resolve."); + } + expect(resolved.resolution.totalDamage).toBe(1); + expect(resolved.resolution.route).toEqual( + expectedKind === "armor" + ? { + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "md", value: 1 }, + convertedDamage: { type: "sdc", value: 100 }, + armor: { + kind: "sdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 10, + after: 0, + }, + body: { + before: { sdc: 120, hitPoints: 18 }, + after: { sdc: 120, hitPoints: 18 }, + }, + finalBlastAbsorbed: true, + } + : { + routingVersion: 2, + kind: "body", + nativeDamage: { type: "md", value: 1 }, + convertedDamage: { type: "sdc", value: 100 }, + armor: { + kind: "sdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 10, + after: 10, + }, + body: { + before: { sdc: 120, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, + }, + lifeState: { before: "alive", after: "alive" }, + }, + ); + expect((await getCharacter(t, defenderId))?.current).toEqual( + expectedKind === "armor" + ? { armor: expectedArmor } + : { armor: expectedArmor, sdc: expectedSdc, hitPoints: 18 }, + ); + }, + ), + ); + + test.each([ + [32, "body", "coma", undefined], + [33, "fatal", "dead", "dead"], + ] as const)( + "persists native damage %i at the coma floor as %s", + async (damage, expectedKind, afterLifeState, storedLifeState) => + withCatalogFixture( + "survival-knife", + (item) => { + const priorDamage = item.damage as Record; + item.damage = { ...priorDamage, formula: "1D100", type: "sdc" }; + }, + async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + attributes: { ...character.attributes, PS: 10 }, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { + rolled: { hitPoints: 18, sdc: 0 }, + }); + const pending = await declarePendingAtTotal( + t, + attackerId, + defenderId, + "survival-knife", + 10, + meleeContext, + ); + + const resolved = await respondWithDamage(t, pending._id, damage, 100); + + if (resolved.status !== "resolved" || resolved.resolution.outcome !== "hit") { + throw new Error("Expected the coma-floor attack to resolve."); + } + expect(resolved.resolution.damageRoll).toEqual({ + dice: [damage], + bonus: 0, + total: damage, + }); + expect(resolved.resolution.totalDamage).toBe(damage); + expect(resolved.resolution.route).toEqual({ + routingVersion: 2, + kind: expectedKind, + nativeDamage: { type: "sdc", value: damage }, + body: { + before: { sdc: 0, hitPoints: 18 }, + after: { sdc: 0, hitPoints: -14 }, + }, + lifeState: { before: "alive", after: afterLifeState }, + }); + expect((await getCharacter(t, defenderId))?.current).toEqual({ + sdc: 0, + hitPoints: -14, + ...(storedLifeState === undefined ? {} : { lifeState: storedLifeState }), + }); + }, + ), + ); +}); + describe("combat response and cancellation", () => { test("take-the-hit rolls damage server-side and atomically records body pool changes", async () => { const t = testDb(); @@ -1296,6 +1786,29 @@ describe("combat response and cancellation", () => { ); }); + test("cleanup cancellation remains legal when a pending combatant dies", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { rolled: ready }); + const pending = await declarePending(t, attackerId, defenderId); + await t.run((ctx) => + ctx.db.patch(defenderId, { + current: { sdc: 0, hitPoints: -14, lifeState: "dead" }, + }), + ); + const deadBeforeCleanup = await getCharacter(t, defenderId); + + const cancelled = await t.mutation(api.combat.cancelAttack, { + exchangeId: pending._id, + }); + + expect(cancelled).toMatchObject({ status: "cancelled" }); + expect(await getCharacter(t, defenderId)).toEqual(deadBeforeCleanup); + }); + test("two concurrent responses have one winner and apply damage exactly once", async () => { const t = testDb(); const attackerId = await createCharacter(t, { @@ -1338,6 +1851,139 @@ describe("combat response and cancellation", () => { }); describe("combat response stale-state finalization", () => { + test("stales defender death before response dice or character writes", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { rolled: ready }); + const pending = await declarePending(t, attackerId, defenderId); + await t.run((ctx) => + ctx.db.patch(defenderId, { + current: { sdc: 0, hitPoints: -14, lifeState: "dead" }, + }), + ); + const deadBeforeResponse = await getCharacter(t, defenderId); + const random = vi.spyOn(Math, "random"); + + try { + const stale = await t.mutation(api.combat.respondToAttack, { + exchangeId: pending._id, + response: { kind: "none" }, + }); + + expect(random).not.toHaveBeenCalled(); + expect(stale).toMatchObject({ status: "stale", reason: "combatStateChanged" }); + expect(await getCharacter(t, defenderId)).toEqual(deadBeforeResponse); + } finally { + random.mockRestore(); + } + }); + + test("stales lost M.D.C. armor readiness before response dice or character writes", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "llw-concealed-light", worn: true, rolledMdc: 39 }], + current: { armor: 39 }, + }); + const pending = await declarePending(t, attackerId, defenderId); + await t.run((ctx) => + ctx.db.patch(defenderId, { + items: [{ itemId: "llw-concealed-light", worn: true }], + current: { sdc: 20, hitPoints: 18 }, + }), + ); + const unreadyBeforeResponse = await getCharacter(t, defenderId); + const random = vi.spyOn(Math, "random"); + + try { + const stale = await t.mutation(api.combat.respondToAttack, { + exchangeId: pending._id, + response: { kind: "none" }, + }); + + expect(random).not.toHaveBeenCalled(); + expect(stale).toMatchObject({ status: "stale", reason: "combatStateChanged" }); + expect(await getCharacter(t, defenderId)).toEqual(unreadyBeforeResponse); + } finally { + random.mockRestore(); + } + }); + + test("stales a selected M.D. weapon change before response dice or character writes", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "wilks-320-laser-pistol" }], + }); + const defenderId = await createCharacter(t, { rolled: ready }); + const pending = await declarePendingAtTotal( + t, + attackerId, + defenderId, + "wilks-320-laser-pistol", + 12, + { kind: "ranged", defenderAware: true, rangeBand: "normal" }, + ); + await t.run((ctx) => + ctx.db.patch(attackerId, { + items: [{ itemId: "ng-33-laser-pistol" }], + }), + ); + const defenderBeforeResponse = await getCharacter(t, defenderId); + const random = vi.spyOn(Math, "random"); + + try { + const stale = await t.mutation(api.combat.respondToAttack, { + exchangeId: pending._id, + response: { kind: "none" }, + }); + + expect(random).not.toHaveBeenCalled(); + expect(stale).toMatchObject({ status: "stale", reason: "combatStateChanged" }); + expect(await getCharacter(t, defenderId)).toEqual(defenderBeforeResponse); + } finally { + random.mockRestore(); + } + }); + + test("safely stales legacy v1 combat tokens before response dice", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { rolled: ready }); + const pending = await declarePending(t, attackerId, defenderId); + await t.run((ctx) => + ctx.db.patch(pending._id, { + attackerStateToken: pending.attackerStateToken.replace("attacker-v2", "attacker-v1"), + defenderStateToken: pending.defenderStateToken.replace("defender-v2", "defender-v1"), + }), + ); + const defenderBeforeResponse = await getCharacter(t, defenderId); + const random = vi.spyOn(Math, "random"); + + try { + const stale = await t.mutation(api.combat.respondToAttack, { + exchangeId: pending._id, + response: { kind: "none" }, + }); + + expect(random).not.toHaveBeenCalled(); + expect(stale).toMatchObject({ status: "stale", reason: "combatStateChanged" }); + expect(await getCharacter(t, defenderId)).toEqual(defenderBeforeResponse); + } finally { + random.mockRestore(); + } + }); + test("stales a legacy attack snapshot that differs from the current weapon profile", async () => { const t = testDb(); const attackerId = await createCharacter(t, { From ff4d35b484710b5a8f413175d0be9bb8ce59ec8c Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 20:58:30 +0800 Subject: [PATCH 15/25] fix(combat): stale newly dead attackers --- packages/backend/tests/combat.test.ts | 33 ++++++++++++++++++++ packages/rules/src/engine/combat-exchange.ts | 1 + packages/rules/tests/combat-exchange.test.ts | 11 +++++++ 3 files changed, 45 insertions(+) diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index 55d2d19..f77699c 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -1851,6 +1851,39 @@ describe("combat response and cancellation", () => { }); describe("combat response stale-state finalization", () => { + test("stales attacker death before response dice or character writes", async () => { + const t = testDb(); + const attackerId = await createCharacter(t, { + rolled: ready, + items: [{ itemId: "survival-knife" }], + }); + const defenderId = await createCharacter(t, { rolled: ready }); + const pending = await declarePending(t, attackerId, defenderId); + await t.run((ctx) => + ctx.db.patch(attackerId, { + current: { sdc: 0, hitPoints: -14, lifeState: "dead" }, + }), + ); + const deadBeforeResponse = await getCharacter(t, attackerId); + const defenderBeforeResponse = await getCharacter(t, defenderId); + const random = vi.spyOn(Math, "random"); + + try { + const stale = await t.mutation(api.combat.respondToAttack, { + exchangeId: pending._id, + response: { kind: "none" }, + }); + + expect(random).not.toHaveBeenCalled(); + expect(stale).toMatchObject({ status: "stale", reason: "combatStateChanged" }); + expect(await getCharacter(t, attackerId)).toEqual(deadBeforeResponse); + expect(await getCharacter(t, defenderId)).toEqual(defenderBeforeResponse); + expect(await t.run((ctx) => ctx.db.get(pending._id))).toEqual(stale); + } finally { + random.mockRestore(); + } + }); + test("stales defender death before response dice or character writes", async () => { const t = testDb(); const attackerId = await createCharacter(t, { diff --git a/packages/rules/src/engine/combat-exchange.ts b/packages/rules/src/engine/combat-exchange.ts index 7dc4e84..2c368bb 100644 --- a/packages/rules/src/engine/combat-exchange.ts +++ b/packages/rules/src/engine/combat-exchange.ts @@ -123,6 +123,7 @@ export function attackerCombatStateToken( sheet.combat.strikeGuns, sheet.combat.criticalStrikeOn, ], + sheet.vitals.lifeState, orderedCombatExchangeRules(rules), weaponIndex, orderedSelectedItem(sheet, weaponIndex), diff --git a/packages/rules/tests/combat-exchange.test.ts b/packages/rules/tests/combat-exchange.test.ts index f51b086..3e8ce41 100644 --- a/packages/rules/tests/combat-exchange.test.ts +++ b/packages/rules/tests/combat-exchange.test.ts @@ -197,6 +197,7 @@ describe("combat-state tokens", () => { first.combat.strikeGuns, first.combat.criticalStrikeOn, ], + first.vitals.lifeState, [ combatExchangeRules.book, [287, 288, 339, 340, 341, 344, 355, 360, 361], @@ -270,6 +271,16 @@ describe("combat-state tokens", () => { ]); }); + test("stales an otherwise-identical attacker sheet when life state becomes dead", () => { + const alive = tokenSheet(); + const dead: CharacterSheet = { + ...alive, + vitals: { ...alive.vitals, lifeState: "dead" }, + }; + + expect(attackerCombatStateToken(dead, 0)).not.toBe(attackerCombatStateToken(alive, 0)); + }); + test("ignores narrative, P.P.E., and unrelated inventory without leaking backstory", () => { const base = tokenSheet(); const baseAttacker = attackerCombatStateToken(base, 0); From 426b24aec4b240db09323f9bb72bd54c419ea619 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 21:18:51 +0800 Subject: [PATCH 16/25] feat(web): present tiered combat outcomes --- .../src/components/combat-exchange-panel.tsx | 4 +- apps/web/src/lib/combat-exchange.ts | 93 ++++++- apps/web/tests/combat-exchange.test.ts | 233 +++++++++++++++++- 3 files changed, 311 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/combat-exchange-panel.tsx b/apps/web/src/components/combat-exchange-panel.tsx index bbe4660..8b201ff 100644 --- a/apps/web/src/components/combat-exchange-panel.tsx +++ b/apps/web/src/components/combat-exchange-panel.tsx @@ -24,6 +24,7 @@ import { combatErrorMessage, combatTargetDisabledReason, combatWeaponChoices, + exchangeResultLabel, exchangeTone, formatExchangeSummary, ownsAsyncResult, @@ -723,7 +724,8 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen exchange.status === "resolved" && flashingIds().has(String(exchange._id)), }} > - {formatExchangeSummary(exchange)} + {exchangeResultLabel(exchange)} + {formatExchangeSummary(exchange)} )} diff --git a/apps/web/src/lib/combat-exchange.ts b/apps/web/src/lib/combat-exchange.ts index 422a8ba..dad0c1f 100644 --- a/apps/web/src/lib/combat-exchange.ts +++ b/apps/web/src/lib/combat-exchange.ts @@ -1,5 +1,5 @@ import { api } from "@riftforge/backend/api"; -import { deriveAttackProfile, type CharacterSheet } from "@riftforge/rules"; +import { deriveAttackProfile, type CharacterSheet, type TieredDamageRoute } from "@riftforge/rules"; import type { FunctionReturnType } from "convex/server"; import { ConvexError } from "convex/values"; @@ -7,6 +7,10 @@ type CombatTargets = FunctionReturnType; export type CombatTargetSummary = CombatTargets[number]; type CombatRecent = FunctionReturnType; export type ExchangeSummary = CombatRecent[number]; +type ResolvedExchange = Extract; +type HitResolution = Extract; +type ExchangeRoute = HitResolution["route"]; +type ProtectionSnapshot = NonNullable; export interface AsyncOwner { routeId: string; @@ -43,10 +47,7 @@ export function combatWeaponChoices(sheet: CharacterSheet): Array<{ ...(profile.supported ? {} : { - disabledReason: - profile.reason === "unsupportedMdWeapon" - ? "Full M.D.C. combat is follow-up work." - : "This weapon mode is not supported.", + disabledReason: "This weapon mode is not supported.", }), }, ]; @@ -70,12 +71,86 @@ export function exchangeTone(exchange: ExchangeSummary): "dim" | "warn" | "bad" if (exchange.status === "pendingDefense" || exchange.status === "stale") return "warn"; if (exchange.status === "cancelled") return "dim"; if (exchange.resolution.outcome === "defended") return "good"; - if (exchange.resolution.outcome === "hit") return "bad"; + if (exchange.resolution.outcome === "hit") { + const route = exchange.resolution.route; + return route.kind === "armor" || (isTieredRoute(route) && route.kind === "stopped") + ? "warn" + : "bad"; + } return "dim"; } const signed = (value: number) => (value >= 0 ? `+${value}` : String(value)); +function isTieredRoute(route: ExchangeRoute): route is TieredDamageRoute { + return "routingVersion" in route && route.routingVersion === 2; +} + +function damageUnit(type: TieredDamageRoute["nativeDamage"]["type"]): "S.D.C." | "M.D." { + return type === "md" ? "M.D." : "S.D.C."; +} + +function formatDamageAmount(amount: TieredDamageRoute["nativeDamage"]): string { + return `${amount.value} ${damageUnit(amount.type)}`; +} + +function armorUnit(armor: ProtectionSnapshot): "S.D.C." | "M.D.C." { + return armor.kind === "mdcArmor" ? "M.D.C." : "S.D.C."; +} + +function formatArmorRoute(armor: ProtectionSnapshot): string { + const unit = armorUnit(armor); + return `ARMOR ${armor.before} ${unit} -> ${armor.after} ${unit}`; +} + +function formatDamageEvidence(route: TieredDamageRoute): string { + const native = formatDamageAmount(route.nativeDamage); + return route.kind !== "stopped" && route.convertedDamage !== undefined + ? `${native} -> ${formatDamageAmount(route.convertedDamage)}` + : native; +} + +function formatBodyRoute(route: Extract): string { + const protection = + route.armor === undefined + ? "UNPROTECTED BODY" + : route.armor.kind === "mdcArmor" && route.armor.before === 0 + ? `DEPLETED M.D.C. SHELL BYPASSED :: ${formatArmorRoute(route.armor)}` + : `PROTECTION BYPASSED :: ${formatArmorRoute(route.armor)}`; + const body = `BODY S.D.C. ${route.body.before.sdc} -> ${route.body.after.sdc} / H.P. ${route.body.before.hitPoints} -> ${route.body.after.hitPoints}`; + const life = `LIFE ${route.lifeState.before.toUpperCase()} -> ${route.lifeState.after.toUpperCase()}`; + const fatal = route.kind === "fatal" ? " :: FATAL — LIFE SIGNS TERMINATED" : ""; + return `${formatDamageEvidence(route)} :: ${protection} :: ${body} :: ${life}${fatal}`; +} + +function formatTieredRoute(route: TieredDamageRoute): string { + switch (route.kind) { + case "stopped": { + const reason = + route.reason === "intactMdcImpervious" + ? "M.D.C. ARMOR IMPERVIOUS — NO EFFECT" + : "DEPLETED M.D.C. SHELL STOPPED STRIKE"; + return `${formatDamageAmount(route.nativeDamage)} -> ${reason} :: ${formatArmorRoute(route.armor)}`; + } + case "armor": + return `${formatDamageEvidence(route)} :: ${formatArmorRoute(route.armor)}${ + route.finalBlastAbsorbed ? " :: FINAL BLAST ABSORBED" : "" + }`; + case "body": + case "fatal": + return formatBodyRoute(route); + } +} + +export function exchangeResultLabel(exchange: ExchangeSummary): string { + if (exchange.status === "pendingDefense") return "AWAITING DEFENSE"; + if (exchange.status === "cancelled") return "CANCELLED"; + if (exchange.status === "stale") return "STALE"; + if (exchange.resolution.outcome === "miss") return "MISS"; + if (exchange.resolution.outcome === "defended") return "DEFENDED"; + return exchange.resolution.route.kind.toUpperCase(); +} + export function formatExchangeSummary(exchange: ExchangeSummary): string { const strike = `d20[${exchange.strikeRoll.die}]${signed(exchange.strikeRoll.bonus)} = ${exchange.strikeRoll.total}`; const lead = `${exchange.attackerName} → ${exchange.defenderName} :: ${exchange.weapon.name} :: ${strike}`; @@ -91,10 +166,14 @@ export function formatExchangeSummary(exchange: ExchangeSummary): string { result.defenseRoll === undefined ? "" : ` :: ${result.response.kind.toUpperCase()} d20[${result.defenseRoll.die}]${signed(result.defenseRoll.bonus)} = ${result.defenseRoll.total}`; - const damage = `${exchange.attack.damageFormula} [${result.damageRoll.dice.join("][")}]${signed(result.damageRoll.bonus)} = ${result.damageRoll.total} RAW`; const multiplier = result.critical ? `CRITICAL ×${result.damageMultiplier}` : `×${result.damageMultiplier}`; + if (isTieredRoute(result.route)) { + const damage = `${exchange.attack.damageFormula} [${result.damageRoll.dice.join("][")}]${signed(result.damageRoll.bonus)} = ${result.damageRoll.total} ${damageUnit(result.route.nativeDamage.type)} RAW`; + return `${lead}${defense} :: ${damage} :: ${multiplier} :: ${formatTieredRoute(result.route)}`; + } + const damage = `${exchange.attack.damageFormula} [${result.damageRoll.dice.join("][")}]${signed(result.damageRoll.bonus)} = ${result.damageRoll.total} RAW`; const remaining = result.route.kind === "armor" ? `ARMOR ${result.route.armor.after}` diff --git a/apps/web/tests/combat-exchange.test.ts b/apps/web/tests/combat-exchange.test.ts index a75bea6..bf750bc 100644 --- a/apps/web/tests/combat-exchange.test.ts +++ b/apps/web/tests/combat-exchange.test.ts @@ -8,6 +8,7 @@ import { combatErrorMessage, combatTargetDisabledReason, combatWeaponChoices, + exchangeResultLabel, exchangeTone, formatExchangeSummary, ownsAsyncResult, @@ -214,8 +215,152 @@ function armorHitExchange(): Extract { }; } +type ResolvedExchange = Extract; +type HitResolution = Extract; +type TieredRoute = Extract; + +function tieredHitExchange(route: TieredRoute): ResolvedExchange { + return { + ...exchangeBase, + attack: { + ...exchangeBase.attack, + damageType: route.nativeDamage.type, + }, + status: "resolved", + resolution: { + outcome: "hit", + reason: "unopposed", + response: { + kind: "none", + bonus: 0, + actionCost: 0, + explanation: "Take the hit.", + defenseModifier: 0, + totalBonus: 0, + }, + critical: false, + damageMultiplier: 1, + damageRoll: { dice: [5], bonus: 1, total: 6 }, + totalDamage: route.nativeDamage.value, + route, + }, + }; +} + +const unchangedBody = { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, +}; + +const stoppedMdcExchange = tieredHitExchange({ + routingVersion: 2, + kind: "stopped", + reason: "intactMdcImpervious", + nativeDamage: { type: "sdc", value: 96 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 10, + after: 10, + }, + body: unchangedBody, +}); + +const convertedMdcArmorExchange = tieredHitExchange({ + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "sdc", value: 496 }, + convertedDamage: { type: "md", value: 4 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 10, + after: 6, + }, + body: unchangedBody, + finalBlastAbsorbed: false, +}); + +const nativeMdcArmorExchange = tieredHitExchange({ + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "md", value: 6 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 10, + after: 4, + }, + body: unchangedBody, + finalBlastAbsorbed: false, +}); + +const finalBlastExchange = tieredHitExchange({ + routingVersion: 2, + kind: "armor", + nativeDamage: { type: "md", value: 21 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 3, + after: 0, + }, + body: unchangedBody, + finalBlastAbsorbed: true, +}); + +const depletedShellBodyExchange = tieredHitExchange({ + routingVersion: 2, + kind: "body", + nativeDamage: { type: "md", value: 1 }, + convertedDamage: { type: "sdc", value: 100 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 0, + after: 0, + }, + body: { + before: { sdc: 120, hitPoints: 18 }, + after: { sdc: 20, hitPoints: 18 }, + }, + lifeState: { before: "alive", after: "alive" }, +}); + +const depletedShellStoppedExchange = tieredHitExchange({ + routingVersion: 2, + kind: "stopped", + reason: "depletedMdcShell", + nativeDamage: { type: "sdc", value: 5 }, + armor: { + kind: "mdcArmor", + itemId: "gladiator", + name: "Gladiator Full Environmental Body Armor", + before: 0, + after: 0, + }, + body: unchangedBody, +}); + +const fatalExchange = tieredHitExchange({ + routingVersion: 2, + kind: "fatal", + nativeDamage: { type: "md", value: 1 }, + convertedDamage: { type: "sdc", value: 100 }, + body: { + before: { sdc: 20, hitPoints: 18 }, + after: { sdc: 0, hitPoints: -14 }, + }, + lifeState: { before: "alive", after: "dead" }, +}); + describe("combat weapon choices", () => { - test("shows only owned weapons and keeps the full M.D.C. boundary visible", () => { + test("shows only owned weapons and enables legal catalog M.D. attacks", () => { const sheet = deriveSheet({ ...combatant, items: [ @@ -253,18 +398,22 @@ describe("combat weapon choices", () => { index: 5, itemId: "wilks-320-laser-pistol", label: "Wilk's 320 Laser Pistol — 1D6 M.D.", - supported: false, - disabledReason: "Full M.D.C. combat is follow-up work.", + supported: true, }, { index: 6, itemId: "wilks-447-laser-rifle", label: "Wilk's 447 Laser Rifle — 3D6 M.D.", - supported: false, - disabledReason: "Full M.D.C. combat is follow-up work.", + supported: true, }, ]); }); + + test("retains generic invalid-mode copy without the obsolete M.D. refusal", () => { + const combatExchangeSource = source("../src/lib/combat-exchange.ts"); + expect(combatExchangeSource).toContain("This weapon mode is not supported."); + expect(combatExchangeSource).not.toContain("Full M.D.C. combat is follow-up work."); + }); }); describe("combat target choices", () => { @@ -341,20 +490,29 @@ describe("combat target choices", () => { }); describe("combat exchange presentation", () => { - test("maps combat state to Ley Terminal tones without a cyan state", () => { + test("maps every result route to its semantic Ley Terminal tone", () => { const tones = [ - exchangeTone(pendingExchange()), - exchangeTone(staleExchange()), + exchangeTone(stoppedMdcExchange), + exchangeTone(nativeMdcArmorExchange), + exchangeTone(depletedShellBodyExchange), + exchangeTone(fatalExchange), exchangeTone(cancelledExchange()), - exchangeTone(missedExchange()), exchangeTone(defendedExchange()), - exchangeTone(bodyHitExchange()), ]; - expect(tones).toEqual(["warn", "warn", "dim", "dim", "good", "bad"]); + expect(tones).toEqual(["warn", "warn", "bad", "bad", "dim", "good"]); expect(tones).not.toContain("cyan"); }); + test("retains pending, stale, miss, and legacy body tone compatibility", () => { + expect([ + exchangeTone(pendingExchange()), + exchangeTone(staleExchange()), + exchangeTone(missedExchange()), + exchangeTone(bodyHitExchange()), + ]).toEqual(["warn", "warn", "dim", "bad"]); + }); + test("formats pending, stale, cancelled, and miss summaries without invented result fields", () => { const lead = "Vesper → Deadboy :: Survival Knife :: d20[12]+3 = 15"; expect(formatExchangeSummary(pendingExchange())).toBe(`${lead} :: AWAITING DEFENSE`); @@ -395,6 +553,55 @@ describe("combat exchange presentation", () => { expect(summary).not.toContain("BODY S.D.C."); expect(summary).not.toContain("CRITICAL"); }); + + test.each([ + [ + "stopped S.D.C. against intact M.D.C.", + stoppedMdcExchange, + "96 S.D.C. -> M.D.C. ARMOR IMPERVIOUS — NO EFFECT :: ARMOR 10 M.D.C. -> 10 M.D.C.", + ], + [ + "persisted S.D.C.-to-M.D. conversion", + convertedMdcArmorExchange, + "496 S.D.C. -> 4 M.D. :: ARMOR 10 M.D.C. -> 6 M.D.C.", + ], + ["native M.D. armor ablation", nativeMdcArmorExchange, "6 M.D. :: ARMOR 10 M.D.C. -> 4 M.D.C."], + [ + "final armor blast", + finalBlastExchange, + "21 M.D. :: ARMOR 3 M.D.C. -> 0 M.D.C. :: FINAL BLAST ABSORBED", + ], + [ + "M.D.-to-body conversion through a depleted shell", + depletedShellBodyExchange, + "1 M.D. -> 100 S.D.C. :: DEPLETED M.D.C. SHELL BYPASSED :: ARMOR 0 M.D.C. -> 0 M.D.C. :: BODY S.D.C. 120 -> 20 / H.P. 18 -> 18 :: LIFE ALIVE -> ALIVE", + ], + [ + "depleted shell stopping S.D.C.", + depletedShellStoppedExchange, + "5 S.D.C. -> DEPLETED M.D.C. SHELL STOPPED STRIKE :: ARMOR 0 M.D.C. -> 0 M.D.C.", + ], + [ + "fatal termination", + fatalExchange, + "1 M.D. -> 100 S.D.C. :: UNPROTECTED BODY :: BODY S.D.C. 20 -> 0 / H.P. 18 -> -14 :: LIFE ALIVE -> DEAD :: FATAL — LIFE SIGNS TERMINATED", + ], + ] as const)("formats $0 from persisted route evidence", (_label, exchange, routeSummary) => { + expect(formatExchangeSummary(exchange)).toBe( + `Vesper → Deadboy :: Survival Knife :: d20[12]+3 = 15 :: 1D6 [5]+1 = 6 ${exchange.attack.damageType === "md" ? "M.D." : "S.D.C."} RAW :: ×1 :: ${routeSummary}`, + ); + }); + + test("labels each exchange result by persisted route or terminal exchange state", () => { + expect([ + exchangeResultLabel(stoppedMdcExchange), + exchangeResultLabel(nativeMdcArmorExchange), + exchangeResultLabel(depletedShellBodyExchange), + exchangeResultLabel(fatalExchange), + exchangeResultLabel(defendedExchange()), + exchangeResultLabel(cancelledExchange()), + ]).toEqual(["STOPPED", "ARMOR", "BODY", "FATAL", "DEFENDED", "CANCELLED"]); + }); }); describe("async result ownership", () => { @@ -538,6 +745,10 @@ describe("combat exchange component contract", () => { test("keeps persisted combat history bounded and uses no magic signal tone", () => { expect(panelSource).toContain("formatExchangeSummary(exchange)"); + expect(panelSource).toContain("exchangeResultLabel(exchange)"); + expect(panelSource).toMatch( + /]*>\s*\{exchangeResultLabel\(exchange\)\}\s*<\/MonoLabel>/, + ); expect(panelSource).toContain("exchangeTone(exchange)"); expect(panelSource).toContain("recent.data()?.slice(0, 20)"); expect(panelSource).toContain('dim: "border-dead text-muted"'); From c2a6a08ac8c2b0da77feec3e7b28a404050f227b Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 21:29:06 +0800 Subject: [PATCH 17/25] fix(web): inherit combat result tones --- apps/web/src/components/combat-exchange-panel.tsx | 2 +- apps/web/tests/combat-exchange.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/combat-exchange-panel.tsx b/apps/web/src/components/combat-exchange-panel.tsx index 8b201ff..b7fec55 100644 --- a/apps/web/src/components/combat-exchange-panel.tsx +++ b/apps/web/src/components/combat-exchange-panel.tsx @@ -724,7 +724,7 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen exchange.status === "resolved" && flashingIds().has(String(exchange._id)), }} > - {exchangeResultLabel(exchange)} + {exchangeResultLabel(exchange)} {formatExchangeSummary(exchange)} )} diff --git a/apps/web/tests/combat-exchange.test.ts b/apps/web/tests/combat-exchange.test.ts index bf750bc..7b5fd0c 100644 --- a/apps/web/tests/combat-exchange.test.ts +++ b/apps/web/tests/combat-exchange.test.ts @@ -758,6 +758,15 @@ describe("combat exchange component contract", () => { expect(panelSource).not.toMatch(/border-ley|text-ley/); }); + test("forces result labels to inherit the semantic row tone over MonoLabel muted text", () => { + expect(uiSource).toContain( + 'font-mono text-[11.5px] tracking-[0.14em] text-muted uppercase ${own.class ?? ""}', + ); + expect(panelSource).toContain( + '{exchangeResultLabel(exchange)}', + ); + }); + test("associates the recent-history disclosure with its controlled list", () => { expect(panelSource).toContain('aria-controls="combat-recent-history"'); expect(panelSource).toContain('
    Date: Tue, 21 Jul 2026 21:53:08 +0800 Subject: [PATCH 18/25] feat(web): render terminal character dossiers --- .../src/components/combat-exchange-panel.tsx | 284 ++++++++-------- apps/web/src/components/sheet-view.tsx | 45 ++- apps/web/src/pages/character-sheet.tsx | 315 +++++++++++------- apps/web/tests/character-sheet.test.ts | 118 +++++++ apps/web/tests/combat-exchange.test.ts | 35 ++ 5 files changed, 528 insertions(+), 269 deletions(-) create mode 100644 apps/web/tests/character-sheet.test.ts diff --git a/apps/web/src/components/combat-exchange-panel.tsx b/apps/web/src/components/combat-exchange-panel.tsx index b7fec55..c7de881 100644 --- a/apps/web/src/components/combat-exchange-panel.tsx +++ b/apps/web/src/components/combat-exchange-panel.tsx @@ -303,6 +303,7 @@ function OutgoingExchangeRow(props: { export interface CombatExchangePanelProps { characterId: Id<"characters">; sheet: CharacterSheet; + gameplayDisabledReason?: string; onTelemetry: (text: string, tone?: TelemetryTone) => void; } @@ -332,6 +333,7 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen const [notice, setNotice] = createSignal(); const [historyExpanded, setHistoryExpanded] = createSignal(false); const [flashingIds, setFlashingIds] = createSignal>(new Set()); + const gameplayEnabled = () => props.gameplayDisabledReason === undefined; const choices = createMemo(() => combatWeaponChoices(props.sheet)); const selectedIndex = createMemo(() => { const raw = weaponIndex(); @@ -353,6 +355,7 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen const attack = selectedSupportedAttack(); const modifier = modifierValue(); return ( + gameplayEnabled() && !busy() && target !== undefined && combatTargetDisabledReason(target) === undefined && @@ -522,147 +525,162 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen return ( COMBAT EXCHANGE -
    -
    + + {(reason) => ( + + LIFE SIGNS TERMINATED + {reason()} + + )} + +
    ATTRIBUTES @@ -458,6 +477,8 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions onRoll={ props.actions && (() => props.actions!.rollCombat("strike", s().combat.strike)) } + disabled={props.gameplayDisabledReason !== undefined} + title={props.gameplayDisabledReason ?? "Roll"} /> props.actions!.rollCombat("parry", s().combat.parry)) } + disabled={props.gameplayDisabledReason !== undefined} + title={props.gameplayDisabledReason ?? "Roll"} /> props.actions!.rollCombat("dodge", s().combat.dodge)) } + disabled={props.gameplayDisabledReason !== undefined} + title={props.gameplayDisabledReason ?? "Roll"} />
    @@ -499,6 +524,8 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions ? () => props.actions!.rollSave(name, save) : undefined } + disabled={props.gameplayDisabledReason !== undefined} + title={props.gameplayDisabledReason ?? "Roll"} /> )} @@ -532,6 +559,8 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions } onRoll={props.actions && (() => props.actions!.rollSkill(skill))} + disabled={props.gameplayDisabledReason !== undefined} + title={props.gameplayDisabledReason ?? "Roll"} /> )} @@ -552,13 +581,14 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions // pay for (or can't measure, pre-roll) goes dead-steel. const ppeLeft = () => s().ppe?.current; const blocked = () => - props.actions === undefined + props.gameplayDisabledReason ?? + (props.actions === undefined ? undefined : ppeLeft() === undefined ? "Roll vitals to cast" : spell.ppe > ppeLeft()! ? "Insufficient P.P.E." - : undefined; + : undefined); return ( )} diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index de9d1f3..cf88001 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -13,7 +13,16 @@ import { type Weapon, } from "@riftforge/rules"; import { useParams } from "@solidjs/router"; -import { createEffect, createSignal, Match, on, Show, Switch, type Accessor } from "solid-js"; +import { + createEffect, + createSignal, + Match, + on, + onCleanup, + Show, + Switch, + type Accessor, +} from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { CombatExchangePanel } from "../components/combat-exchange-panel.tsx"; import { NarrativeFields } from "../components/narrative-fields.tsx"; @@ -25,6 +34,8 @@ import { createMutation, createQuery } from "../lib/convex.ts"; import { fromNarrative, toNarrative } from "../lib/narrative.ts"; import { createTelemetry, d20Line, machineName } from "../lib/telemetry.ts"; +const TERMINAL_GAMEPLAY_REASON = "Life signs terminated; gameplay actions are unavailable."; + /** Edit the player-authored file fields in place; saves via updateNarrative. */ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | undefined }) { const updateNarrative = createMutation(convex, api.characters.updateNarrative); @@ -32,6 +43,9 @@ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | u const [form, setForm] = createStore(fromNarrative(props.narrative)); const [saving, setSaving] = createSignal(false); const [error, setError] = createSignal(); + let routeEpoch = 0; + const ownsRoute = (owner: { routeId: Id<"characters">; routeEpoch: number }) => + owner.routeId === props.id && owner.routeEpoch === routeEpoch; // Never carry one character's draft into another's file: reset the form // whenever the route id changes, regardless of mount timing. @@ -39,25 +53,33 @@ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | u on( () => props.id, () => { + routeEpoch += 1; setForm(reconcile(fromNarrative(props.narrative))); setOpen(false); + setSaving(false); setError(undefined); }, { defer: true }, ), ); + onCleanup(() => { + routeEpoch += 1; + }); const save = async () => { if (saving()) return; + const owner = { routeId: props.id, routeEpoch }; setError(undefined); setSaving(true); try { - await updateNarrative({ id: props.id, narrative: toNarrative({ ...form }) }); + await updateNarrative({ id: owner.routeId, narrative: toNarrative({ ...form }) }); + if (!ownsRoute(owner)) return; setOpen(false); } catch (err) { + if (!ownsRoute(owner)) return; setError(err instanceof Error ? err : new Error(String(err))); } finally { - setSaving(false); + if (ownsRoute(owner)) setSaving(false); } }; @@ -108,6 +130,8 @@ export function CharacterSheetPage() { // `characters.sheet` validates as `v.any()` (shape owned by @riftforge/rules), // so re-pin the rules-layer type here. const sheet = query.data as Accessor; + const gameplayDisabledReason = () => + sheet()?.vitals.lifeState === "dead" ? TERMINAL_GAMEPLAY_REASON : undefined; const rollVitals = createMutation(convex, api.characters.rollVitals); const castSpellMutation = createMutation(convex, api.characters.castSpell); const applyDamageMutation = createMutation(convex, api.characters.applyDamage); @@ -139,6 +163,10 @@ export function CharacterSheetPage() { // requests and route changes bump it, so a stale settle can never release a // gate it no longer owns — not even back on the same dossier. let treatToken = 0; + let routeEpoch = 0; + const routeOwner = () => ({ routeId: id(), routeEpoch }); + const ownsRoute = (owner: ReturnType) => + owner.routeId === id() && owner.routeEpoch === routeEpoch; // A new dossier starts with a fresh log: rolls belong to the character // they were rolled for, not whoever the page shows next. @@ -146,6 +174,7 @@ export function CharacterSheetPage() { on( id, () => { + routeEpoch += 1; telemetry.reset(); setRollError(undefined); setDamageInput(""); @@ -160,6 +189,10 @@ export function CharacterSheetPage() { { defer: true }, ), ); + onCleanup(() => { + routeEpoch += 1; + treatToken += 1; + }); /** Convex mutation errors carry an "Uncaught Error: …" preamble — strip to the message. */ const reason = (error: unknown): string => { @@ -174,7 +207,7 @@ export function CharacterSheetPage() { // character can't afford. Like all persisting actions, a result that comes // back after the dossier switched characters is dropped. const cast = async (spell: Spell) => { - const castFor = id(); + const owner = routeOwner(); // Exclusive either/or heals (Light Healing) need a pool choice: prefer // the wounded pool (H.P. if down, else S.D.C.) of the character the heal // LANDS on. Today that is always the caster (`sheet()`), and the only @@ -189,11 +222,11 @@ export function CharacterSheetPage() { } try { const result = await castSpellMutation({ - id: castFor, + id: owner.routeId, spellId: spell.id, ...(healPool !== undefined ? { healPool } : {}), }); - if (id() !== castFor) return; + if (!ownsRoute(owner)) return; // Healing spells report what actually landed (post-clamp), per pool. const healed = result.healed ? ` → ${[ @@ -208,7 +241,7 @@ export function CharacterSheetPage() { "magic", ); } catch (error) { - if (id() !== castFor) return; + if (!ownsRoute(owner)) return; telemetry.log(`> CAST :: ${spell.name.toUpperCase()} — REFUSED (${reason(error)})`, "bad"); } }; @@ -226,33 +259,33 @@ export function CharacterSheetPage() { } return; } - const restedFor = id(); + const owner = routeOwner(); try { - const result = await restMutation({ id: restedFor, hours, mode }); - if (id() !== restedFor) return; + const result = await restMutation({ id: owner.routeId, hours, mode }); + if (!ownsRoute(owner)) return; setRestHours(""); telemetry.log( `> ${verb} :: ${hours} HR — P.P.E. +${result.gained} [${result.ppe.current}/${result.ppe.max}]`, "magic", ); } catch (error) { - if (id() !== restedFor) return; + if (!ownsRoute(owner)) return; telemetry.log(`> ${verb} :: REFUSED (${reason(error)})`, "bad"); } }; const leyDraw = async () => { - const drewFor = id(); + const owner = routeOwner(); const nexus = atNexus(); try { - const result = await leyLineDrawMutation({ id: drewFor, melees: 1, atNexus: nexus }); - if (id() !== drewFor) return; + const result = await leyLineDrawMutation({ id: owner.routeId, melees: 1, atNexus: nexus }); + if (!ownsRoute(owner)) return; telemetry.log( `> LEY DRAW${nexus ? " (NEXUS)" : ""} :: +${result.gained} P.P.E. [${result.ppe.current}/${result.ppe.max}]`, "magic", ); } catch (error) { - if (id() !== drewFor) return; + if (!ownsRoute(owner)) return; telemetry.log(`> LEY DRAW :: REFUSED (${reason(error)})`, "bad"); } }; @@ -269,27 +302,27 @@ export function CharacterSheetPage() { } setTreating(true); const token = ++treatToken; - const treatedFor = id(); + const owner = routeOwner(); const pro = professional(); try { const result = await treatMutation({ - id: treatedFor, + id: owner.routeId, professional: pro, ...(override !== undefined ? { day: override } : {}), }); - if (id() !== treatedFor) return; + if (!ownsRoute(owner)) return; setDayInput(""); // back to following the stored course telemetry.log( `> TREATMENT :: DAY ${result.day}${pro ? " (PRO)" : ""} — H.P. +${result.gained.hitPoints} · S.D.C. +${result.gained.sdc}`, "good", ); } catch (error) { - if (id() !== treatedFor) return; + if (!ownsRoute(owner)) return; telemetry.log(`> TREATMENT :: REFUSED (${reason(error)})`, "bad"); } finally { // Only the gate's current owner may release it: a stale settle must not // unlock a treat a newer request has in flight. - if (token === treatToken) setTreating(false); + if (ownsRoute(owner) && token === treatToken) setTreating(false); } }; @@ -302,15 +335,15 @@ export function CharacterSheetPage() { if (raw !== "") telemetry.log(`> DAMAGE :: REFUSED (not a whole number: "${raw}")`, "bad"); return; } - const damagedFor = id(); + const owner = routeOwner(); const strikesArmor = toArmor(); try { const next = await applyDamageMutation({ - id: damagedFor, + id: owner.routeId, amount, ...(strikesArmor ? { toArmor: true } : {}), }); - if (id() !== damagedFor) return; + if (!ownsRoute(owner)) return; setDamageInput(""); telemetry.log( "armor" in next @@ -319,7 +352,7 @@ export function CharacterSheetPage() { "bad", ); } catch (error) { - if (id() !== damagedFor) return; + if (!ownsRoute(owner)) return; telemetry.log(`> DAMAGE :: REFUSED (${reason(error)})`, "bad"); } }; @@ -327,18 +360,18 @@ export function CharacterSheetPage() { // Inventory writes persist; like every persisting action, a result that // arrives after the dossier switched characters is dropped. const acquire = async (itemId: string) => { - const forId = id(); + const owner = routeOwner(); const name = (getItem(itemId)?.name ?? itemId).toUpperCase(); try { - const result = await addItemMutation({ id: forId, itemId }); - if (id() !== forId) return; + const result = await addItemMutation({ id: owner.routeId, itemId }); + if (!ownsRoute(owner)) return; // Dice-capacity suits (LLW concealed) are rated at acquisition. telemetry.log( `> ACQUIRE :: ${name}${result.rolledMdc !== undefined ? ` — SUIT RATED ${result.rolledMdc} M.D.C.` : ""}`, "good", ); } catch (error) { - if (id() !== forId) return; + if (!ownsRoute(owner)) return; telemetry.log(`> ACQUIRE :: ${name} — REFUSED (${reason(error)})`, "bad"); } }; @@ -352,45 +385,45 @@ export function CharacterSheetPage() { }); const discard = async (index: number, entry: SheetEquipmentEntry) => { - const forId = id(); + const owner = routeOwner(); const name = entry.item.name.toUpperCase(); try { - await removeItemMutation({ id: forId, index, expect: expectOf(entry) }); - if (id() !== forId) return; + await removeItemMutation({ id: owner.routeId, index, expect: expectOf(entry) }); + if (!ownsRoute(owner)) return; telemetry.log(`> DISCARD :: ${name}`); } catch (error) { - if (id() !== forId) return; + if (!ownsRoute(owner)) return; telemetry.log(`> DISCARD :: ${name} — REFUSED (${reason(error)})`, "bad"); } }; const equip = async (index: number | null, entry?: SheetEquipmentEntry) => { - const forId = id(); + const owner = routeOwner(); const name = entry?.item.name.toUpperCase(); try { // Wear and doff both name the instance the click saw: doffing verifies // the WORN suit, so a racing swap can't be unequipped blind. await equipArmorMutation({ - id: forId, + id: owner.routeId, index, ...(entry !== undefined ? { expect: expectOf(entry) } : {}), }); - if (id() !== forId) return; + if (!ownsRoute(owner)) return; telemetry.log(index === null ? "> DOFF :: ARMOR OFFLINE" : `> EQUIP :: ${name} — WORN`); } catch (error) { - if (id() !== forId) return; + if (!ownsRoute(owner)) return; telemetry.log(`> EQUIP :: ${name ?? "—"} — REFUSED (${reason(error)})`, "bad"); } }; const restore = async () => { - const restoredFor = id(); + const owner = routeOwner(); try { - await restoreVitals({ id: restoredFor }); - if (id() !== restoredFor) return; + await restoreVitals({ id: owner.routeId }); + if (!ownsRoute(owner)) return; telemetry.log("> RESTORE :: ALL POOLS FULL", "good"); } catch (error) { - if (id() !== restoredFor) return; + if (!ownsRoute(owner)) return; telemetry.log(`> RESTORE :: REFUSED (${reason(error)})`, "bad"); } }; @@ -440,16 +473,16 @@ export function CharacterSheetPage() { const roll = async () => { // If the dossier switches while the mutation is in flight, the result // belongs to the character it was rolled for — drop it silently. - const rolledFor = id(); + const owner = routeOwner(); setRollError(undefined); try { - const rolled = await rollVitals({ id: rolledFor }); - if (id() !== rolledFor) return; + const rolled = await rollVitals({ id: owner.routeId }); + if (!ownsRoute(owner)) return; telemetry.log( `> ROLL VITALS :: H.P. ${rolled.hitPoints} · S.D.C. ${rolled.sdc}${rolled.ppe !== undefined ? ` · P.P.E. ${rolled.ppe}` : ""} — LOCKED`, ); } catch (error) { - if (id() !== rolledFor) return; + if (!ownsRoute(owner)) return; setRollError(error instanceof Error ? error : new Error(String(error))); telemetry.log("> ROLL VITALS :: WRITE FAILED", "bad"); } @@ -466,116 +499,140 @@ export function CharacterSheetPage() { {/* Non-keyed on purpose: subscription updates flow through - fine-grained reactivity instead of remounting the sheet, so the + fine-grained reactivity instead of remounting the sheet, so the strike flash can see values change and editor state survives. */}
    - +
    + } + > + {(reason) => ( + + LIFE SIGNS TERMINATED + {reason()} + + )} + } /> diff --git a/apps/web/tests/character-sheet.test.ts b/apps/web/tests/character-sheet.test.ts new file mode 100644 index 0000000..e7b2460 --- /dev/null +++ b/apps/web/tests/character-sheet.test.ts @@ -0,0 +1,118 @@ +/// + +import { existsSync, readFileSync } from "node:fs"; +import { describe, expect, test } from "vite-plus/test"; + +const TERMINAL_REASON = "Life signs terminated; gameplay actions are unavailable."; + +function source(relative: string): string { + const url = new URL(relative, import.meta.url); + return existsSync(url) ? readFileSync(url, "utf8") : ""; +} + +const characterSheetSource = source("../src/pages/character-sheet.tsx"); +const sheetViewSource = source("../src/components/sheet-view.tsx"); + +describe("terminal sheet dossier", () => { + test("retains the dossier while disabling every gameplay roll with the terminal reason", () => { + expect(characterSheetSource).toContain(TERMINAL_REASON); + expect(sheetViewSource).toContain("gameplayDisabledReason?: string;"); + expect(sheetViewSource).toContain("LIFE SIGNS TERMINATED"); + expect(sheetViewSource).toContain("{reason()}"); + expect(sheetViewSource).toContain("VITALS"); + expect(sheetViewSource).toContain("EQUIPMENT — MANIFEST"); + expect(sheetViewSource).toContain("PERSONNEL FILE — NARRATIVE"); + + expect( + sheetViewSource.match(/disabled=\{props\.gameplayDisabledReason !== undefined\}/g), + ).toHaveLength(5); + expect( + sheetViewSource.match(/title=\{props\.gameplayDisabledReason \?\? "Roll"\}/g), + ).toHaveLength(5); + expect(sheetViewSource).toContain('title={props.gameplayDisabledReason ?? "Roll damage"}'); + expect(sheetViewSource).toContain( + "aria-disabled={props.gameplayDisabledReason !== undefined || undefined}", + ); + const spellGate = sheetViewSource.indexOf("const blocked = () =>"); + const terminalPrecedence = sheetViewSource.indexOf( + "props.gameplayDisabledReason ??", + spellGate, + ); + const affordability = sheetViewSource.indexOf('"Insufficient P.P.E."', spellGate); + expect(spellGate).toBeGreaterThanOrEqual(0); + expect(terminalPrecedence).toBeGreaterThan(spellGate); + expect(terminalPrecedence).toBeLessThan(affordability); + }); + + test("keeps inventory controls usable for a terminal dossier", () => { + expect(sheetViewSource).toContain("props.actions!.equipArmor("); + expect(sheetViewSource).toContain("props.actions!.discardItem("); + expect(sheetViewSource).toContain(" { + expect(sheetViewSource).toContain("props.actions!.rollCombat"); + expect(sheetViewSource).toContain("props.actions!.rollSave"); + expect(sheetViewSource).toContain("props.actions!.rollSkill"); + expect(sheetViewSource).toContain("props.actions!.castSpell"); + expect(sheetViewSource).toContain("props.actions!.rollWeapon"); + expect(sheetViewSource).not.toContain("actions={undefined}"); + }); +}); + +describe("terminal command rail and parameter navigation", () => { + test("keeps narrative and telemetry but replaces terminal gameplay controls with the danger alert", () => { + expect(characterSheetSource).toContain("const TERMINAL_GAMEPLAY_REASON ="); + expect(characterSheetSource).toContain('sheet()?.vitals.lifeState === "dead"'); + expect(characterSheetSource).toContain( + "'); + expect(characterSheetSource).toContain('{"> Roll Vitals"}'); + expect(characterSheetSource).toContain('{"> Damage"}'); + expect(characterSheetSource).toContain('{"> Full Restore"}'); + expect(characterSheetSource).toContain('{"> Rest"}'); + expect(characterSheetSource).toContain('{"> Meditate"}'); + expect(characterSheetSource).toContain('{"> Ley Draw"}'); + expect(characterSheetSource).toContain('{"> Treatment Day"}'); + + const rail = characterSheetSource.indexOf(" Roll Vitals"}', fallback); + const terminalAlert = characterSheetSource.indexOf("LIFE SIGNS TERMINATED", roll); + expect(rail).toBeGreaterThanOrEqual(0); + expect(terminalGate).toBeGreaterThan(rail); + expect(fallback).toBeGreaterThan(terminalGate); + expect(roll).toBeGreaterThan(fallback); + expect(terminalAlert).toBeGreaterThan(roll); + }); + + test("resets every command draft and invalidates in-flight results on route changes", () => { + expect(characterSheetSource).toContain("let routeEpoch = 0"); + const resetStart = characterSheetSource.indexOf("createEffect(\n on(\n id,"); + const resetEnd = characterSheetSource.indexOf("\n );", resetStart); + expect(resetStart).toBeGreaterThanOrEqual(0); + expect(resetEnd).toBeGreaterThan(resetStart); + const reset = characterSheetSource.slice(resetStart, resetEnd); + expect(reset).toContain("routeEpoch += 1"); + expect(reset).toContain('setDamageInput("")'); + expect(reset).toContain("setToArmor(false)"); + expect(reset).toContain('setRestHours("")'); + expect(reset).toContain("setAtNexus(false)"); + expect(reset).toContain("setProfessional(false)"); + expect(reset).toContain('setDayInput("")'); + expect(characterSheetSource.match(/const owner = routeOwner\(\)/g)).toHaveLength(10); + expect(characterSheetSource.match(/if \(!ownsRoute\(owner\)\) return/g)).toHaveLength(22); + expect(characterSheetSource).not.toContain(" { }); describe("combat exchange component contract", () => { + test("suppresses terminal declaration and response controls while retaining cancellation and history", () => { + expect(panelSource).toContain("gameplayDisabledReason?: string;"); + expect(panelSource).toContain( + "const gameplayEnabled = () => props.gameplayDisabledReason === undefined", + ); + expect(panelSource).toContain("LIFE SIGNS TERMINATED"); + expect(panelSource).toContain(''); + expect(panelSource).toContain(""); + + const panelStart = panelSource.indexOf(''); + const declarationGate = panelSource.indexOf("", panelStart); + const incomingGate = panelSource.indexOf( + "", + declarationGate + 1, + ); + const declaration = panelSource.indexOf('{busy() ? "> TRANSMITTING…" : "> DECLARE ATTACK"}'); + const incoming = panelSource.indexOf(" CANCELLING…" : "> CANCEL"}'); + const recent = panelSource.indexOf('id="combat-recent-history"'); + expect(panelStart).toBeGreaterThanOrEqual(0); + expect(declarationGate).toBeGreaterThan(panelStart); + expect(incomingGate).toBeGreaterThan(declarationGate); + expect(declaration).toBeGreaterThanOrEqual(0); + expect(incoming).toBeGreaterThanOrEqual(0); + expect(outgoing).toBeGreaterThanOrEqual(0); + expect(cancellation).toBeGreaterThanOrEqual(0); + expect(recent).toBeGreaterThanOrEqual(0); + expect(declaration).toBeGreaterThan(declarationGate); + expect(declaration).toBeLessThan(incomingGate); + expect(incoming).toBeGreaterThan(incomingGate); + expect(incoming).toBeLessThan(outgoing); + expect(outgoing).toBeLessThan(recent); + }); + test("shares native select and toggle primitives without rounded or decorative styling", () => { expect(uiSource).toContain('export function SelectInput(props: ComponentProps<"select">)'); expect(uiSource).toContain("notch-8 border border-line bg-noir"); From 780dd91ff1719fb90ceafd9711dc7e9ef1b635b5 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 22:19:10 +0800 Subject: [PATCH 19/25] fix(combat): harden mega-damage invariants --- packages/backend/convex/combat.ts | 7 +------ packages/rules/src/engine/combat-exchange.ts | 2 +- packages/rules/tests/combat-exchange.test.ts | 10 +++++++++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/backend/convex/combat.ts b/packages/backend/convex/combat.ts index 929e308..43d9d37 100644 --- a/packages/backend/convex/combat.ts +++ b/packages/backend/convex/combat.ts @@ -217,12 +217,7 @@ export const declareAttack = mutation({ const attack = deriveAttackProfile(attackerSheet, args.weaponIndex); if (!attack.supported) { - combatFailure( - attack.reason, - attack.reason === "unsupportedMdWeapon" - ? "M.D. weapons require the full M.D.C. combat follow-up." - : "The selected item is not a supported weapon mode.", - ); + combatFailure(attack.reason, "The selected item is not a supported weapon mode."); } const context = parseDeclaredContext(attack, args.context); diff --git a/packages/rules/src/engine/combat-exchange.ts b/packages/rules/src/engine/combat-exchange.ts index 2c368bb..199c3e3 100644 --- a/packages/rules/src/engine/combat-exchange.ts +++ b/packages/rules/src/engine/combat-exchange.ts @@ -183,7 +183,7 @@ export interface WeaponInstanceSnapshot { export type AttackProfile = | { supported: false; - reason: "weaponMissingOrChanged" | "unsupportedWeaponMode" | "unsupportedMdWeapon"; + reason: "weaponMissingOrChanged" | "unsupportedWeaponMode"; weapon?: WeaponInstanceSnapshot & { name: string }; } | { diff --git a/packages/rules/tests/combat-exchange.test.ts b/packages/rules/tests/combat-exchange.test.ts index 3e8ce41..1895017 100644 --- a/packages/rules/tests/combat-exchange.test.ts +++ b/packages/rules/tests/combat-exchange.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vite-plus/test"; +import { describe, expect, expectTypeOf, test } from "vite-plus/test"; import { attackerCombatStateToken, armorSchema, @@ -601,6 +601,14 @@ describe("combat-state tokens", () => { }); describe("weapon attack profiles", () => { + test("exposes only currently reachable unsupported attack reasons", () => { + type UnsupportedAttackReason = Extract["reason"]; + + expectTypeOf().toEqualTypeOf< + "weaponMissingOrChanged" | "unsupportedWeaponMode" + >(); + }); + test("classifies real S.D.C. melee and firearm instances", () => { const sheet = combatSheet({ items: [{ itemId: "survival-knife" }, { itemId: "automatic-pistol" }], From f67236e2badbb539dcd3b866ed5fb9ace1e915b6 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Tue, 21 Jul 2026 23:31:26 +0800 Subject: [PATCH 20/25] fix(web): harden dossier shell acceptance --- apps/web/index.html | 1 + apps/web/src/components/sheet-view.tsx | 6 +++--- apps/web/src/layouts/app.tsx | 6 +++--- apps/web/tests/character-sheet.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index ac8e5a6..3b2d74e 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,6 +4,7 @@ RiftForge + props.sheet; return (
    -
    +
    @@ -384,7 +384,7 @@ export function SheetView(props: {
    -
    +
    {(alignment) => ( @@ -397,7 +397,7 @@ export function SheetView(props: { // to show — hide the block unless at least one row has a value. when={APPEARANCE_ROWS.some(([field]) => s().narrative?.appearance?.[field])} > -
    +
    s().narrative?.appearance?.[field])}> {([field, label]) => (
    diff --git a/apps/web/src/layouts/app.tsx b/apps/web/src/layouts/app.tsx index dde160d..a45248d 100644 --- a/apps/web/src/layouts/app.tsx +++ b/apps/web/src/layouts/app.tsx @@ -24,8 +24,8 @@ export function AppLayout(props: RouteSectionProps) {
    -
    -