From 42768f8d6b2695b621afe1851e89ec0107f330b9 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 19 Jul 2026 23:13:05 +0800 Subject: [PATCH 01/21] docs: design S.D.C. combat exchange --- .../2026-07-19-sdc-combat-exchange-design.md | 581 ++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 .codex/superpowers/specs/2026-07-19-sdc-combat-exchange-design.md diff --git a/.codex/superpowers/specs/2026-07-19-sdc-combat-exchange-design.md b/.codex/superpowers/specs/2026-07-19-sdc-combat-exchange-design.md new file mode 100644 index 0000000..c0569da --- /dev/null +++ b/.codex/superpowers/specs/2026-07-19-sdc-combat-exchange-design.md @@ -0,0 +1,581 @@ +# S.D.C. Combat Exchange Design + +**Date:** 2026-07-19 +**Status:** Approved in conversation +**Branch:** `feat/sdc-combat-exchange` +**Primary issue:** [#44 — A.R. and armor damage in combat](https://github.com/StreamDemon/RiftForge/issues/44) + +## Goal + +Deliver a rules-faithful, multiplayer-safe S.D.C. hostile-combat vertical slice +without prematurely building the complete VTT encounter system or inventing +Mega-Damage outcomes the current character model cannot represent. + +This slice will: + +- classify an owned weapon attack as melee or ranged and derive its legal bonuses; +- let the attacker declare a target and GM-adjudicated combat context; +- roll the strike on the server and persist a pending defense when appropriate; +- let the defender or GM choose an engine-authorized parry, dodge, automatic + dodge, or no defense; +- revalidate the exchange against current attacker and defender state; +- roll defense and damage on the server; +- route S.D.C. damage to artificial S.D.C. armor or the defender's body; +- apply the result atomically to the correct persisted pool; and +- surface incoming, outgoing, and recent exchanges on the live dossier. + +The rules engine enumerates and validates choices. It never chooses how a player +role-plays a character and never substitutes for the Game Master's situational +judgment. + +## Approved decisions + +1. **S.D.C. tier only.** Full M.D.C. resolution is a separate follow-up. +2. **Persisted two-phase exchange.** The attacker declares and rolls first; a + potential hit waits for a defender/GM response. +3. **No full encounter model.** Initiative, rounds, action budgets, ownership, + and map positioning remain future work. +4. **No natural A.R. path.** Rendered RUE p.339 explicitly says Natural A.R. does + not apply in Rifts. Issue #44's contrary wording is stale and will be corrected. +5. **M.D.C. fails safely.** M.D. weapons and M.D.C.-protected targets are visible + but cannot enter this resolver. Refusal happens before dice or state changes. +6. **GM context is explicit and recorded.** Situational facts and modifiers are + inputs, not hidden assumptions or client-side arithmetic. +7. **State drift invalidates the exchange.** A response never lands against a + changed weapon, build, armor layer, or damage pool. + +## Printed rules evidence + +All facts were checked visually against rendered pages of the scanned local Rifts +Ultimate Edition rulebook. The PDF has no usable text layer. + +| Printed page | Rule used in this design | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 287 | Physical S.D.C. is depleted before Hit Points. Artificial S.D.C. armor has A.R. plus an ablative S.D.C. pool. A strike at or below A.R. damages armor; a strike above A.R. damages the wearer. Depleted armor stops protecting future hits. | +| 288 | One M.D. equals 100 S.D.C.; M.D.C. armor is generally impervious to S.D.C./Hit Point damage and has no A.R. Full interaction is outside this slice. | +| 339 | The attacker rolls first. A total of 1-4 misses in ordinary combat. Artificial S.D.C. armor routing uses the completed strike total. The page explicitly says Natural A.R. does not apply in Rifts. | +| 340 | A natural 20 is a critical unless met by a natural-20 defense. Defenders may choose parry or dodge. Trained parry is action-free; untrained parry costs the next action. Bare-handed weapon parries receive no normal parry bonus. | +| 341 | Handheld melee weapons add applicable P.S./Hand-to-Hand damage bonuses. Add bonuses before critical multiplication. Dodging costs an action; automatic dodge does not. | +| 344 | Automatic dodge is an explicit ability, not an automatic success, and uses only bonuses that specifically apply to it. | +| 345-346 | Dodge ties favor the defender. A roll of 1 always misses. A natural 20 can only be beaten by a natural-20 defense. | +| 355 | The last point of armor absorbs the entire destroying hit. Subsequent attacks reach the wearer. This supports the same no-spill rule used for S.D.C. armor. | +| 360-361 | Modern firearms do not use P.P. or general Hand-to-Hand strike bonuses. Ranged attacks normally require 8+ to strike. Gunfire may be dodged only when seen/anticipated, with point-blank and close-range penalties. | + +The existing p.346 constants remain page-stamped content. New ranged-target and +range-penalty constants must also live in page-stamped, load-validated content +rather than source-code literals. + +## Scope + +### In scope + +- Owned knife and axe attacks as melee S.D.C. weapon attacks. +- Owned handgun and submachine-gun attacks as ranged S.D.C. weapon attacks. +- Current untrained firearm behavior plus any already-modeled, specifically named + gun bonus such as `strikeGuns`. +- Server-controlled strike, defense, and damage dice. +- Parry, dodge, automatic dodge, and taking the hit. +- GM-declared awareness, parry mode, range band, and situational modifiers. +- Artificial S.D.C. armor A.R. and ablation in the pure rules layer. +- Unarmored S.D.C./Hit Point application in the live backend. +- S.D.C. armor persistence automatically when page-stamped S.D.C. armor content + becomes available. +- M.D.C. boundary messages and a dedicated follow-up tracker. +- Persisted incoming/outgoing/recent exchange UI. + +### Out of scope + +- M.D.-to-M.D.C. damage, S.D.C.-to-M.D.C. conversion, impervious armor, and the + usually-lethal result of M.D. against an unprotected mortal. +- Natural A.R.; it does not apply in this rulebook's Rifts combat rules. +- Initiative, melee rounds, attacks-per-melee expenditure, or turn enforcement. +- Authentication, player ownership, GM roles, permissions, and adversarial clients. +- VTT maps, measured distance, line-of-sight geometry, or cover geometry. +- Called/aimed shots, bursts, payload tracking, thrown weapon modes, hit locations, + paired weapons, entangle, disarm, roll with impact, and weapon breakage. +- Unarmed strikes and special Hand-to-Hand moves. +- Long-term combat-log retention or encounter archival policy. +- A generic spell attack/effect interpreter. + +## Architecture + +The feature has three boundaries with one-directional authority: + +```text +page-stamped content + pure rules + | + v +Convex exchange ledger + atomic mutations + | + v +SolidJS declaration/response/history UI +``` + +### Rules package + +The existing `resolveStrike(input)` remains the low-level opposed-roll primitive. +A new pure combat-exchange layer sits above it and owns: + +- attack classification; +- melee versus ranged minimum strike totals; +- attacker and defender bonus selection; +- legal defense enumeration and action-cost metadata; +- critical damage multiplication; +- protection classification; +- artificial S.D.C. armor routing; and +- pure body/armor result calculation. + +The layer accepts already-derived sheets, explicit GM context, completed rolls, and +damage input. It never reads Convex, generates identifiers, or mutates a character. +Random rolls remain injectable at the function boundary. + +### Backend + +Convex owns exchange identity, synchronization, dice, immutable outcome records, +and the character write. The response mutation reloads and re-derives both +characters before it applies anything. + +### Web + +SolidJS presents server-derived options and results. It does not reproduce bonus, +A.R., critical, or routing calculations. Client state is limited to an unsubmitted +declaration/response form and in-flight ownership tokens. + +## Pure rules model + +### Attack classification + +Weapon category defines attack kind; damage type defines tier support. + +| Weapon category | Attack kind | #44 support | +| ----------------------------- | ----------- | ---------------------------------------------- | +| `knife`, `axe` | melee | Supported when damage type is `sdc` | +| `handgun`, `submachineGun` | ranged | Supported when damage type is `sdc` | +| `energyPistol`, `energyRifle` | ranged | Refused because current catalog damage is `md` | + +Thrown use of a knife or axe is not inferred from category. It requires a future +explicit attack mode and is outside this slice. + +### Attack profile + +The engine derives a profile containing at least: + +- attack kind; +- minimum successful strike total; +- base strike bonus and named sources; +- damage formula; +- applicable flat damage bonus; +- critical threshold; +- damage type; +- weapon index and instance snapshot; and +- a supported/refused result with a stable reason code. + +Melee uses the normal derived strike and damage bonuses. Ranged uses only bonuses +that specifically apply to guns. P.P. and general Hand-to-Hand strike or damage +bonuses never leak into firearms. + +The current skill catalog contains no structured modern W.P. entries. Current +characters therefore fire as untrained shooters except for a separately modeled, +specifically named gun bonus already present in the combat profile. The design +leaves a typed proficiency-bonus seam but does not invent missing content. + +### GM context + +The declaration stores explicit situational context: + +- `defenderAware`: whether the defender sees and knows the attack is coming; +- melee `parryMode`: `unavailable`, `standard`, or `bareHanded`; +- ranged `rangeBand`: `pointBlank`, `close`, or `normal`; +- optional `strikeModifier`; and +- `strikeModifierReason`, required when the modifier is nonzero. + +A situational modifier must be a safe integer from -100 through +100 inclusive. +That application bound prevents corrupt or accidental extreme values; it does not +purport to be a printed rule. Book-defined modifiers such as ranged dodge penalties +are derived by the engine and are not re-entered as situational modifiers. + +### Defense options + +Each legal option includes its kind, derived bonus, action-cost metadata, and a +short explanation for the UI and persisted result. + +#### Melee + +- `parry` is offered only when `parryMode` is not `unavailable` and the defender + is aware of the attack. + - `standard` uses the derived parry bonus. + - `bareHanded` uses no ordinary parry bonus against the weapon. + - formal Hand-to-Hand training makes the parry action-free; otherwise the + metadata records a one-action cost. +- `dodge` is offered when the defender is aware and records a one-action cost. +- `autoDodge` is offered only when the sheet explicitly grants that capability; + it records zero action cost and uses only the automatic-dodge bonus. +- `none` is always available as “take the hit.” + +#### Ranged + +- Only dodge-family defense is available, and only when the defender is aware. +- The bonus excludes general Hand-to-Hand dodge bonuses. +- The engine applies the printed point-blank or close-range penalty from content. +- Automatic dodge is offered only if the sheet explicitly grants the capability; + any bonus still obeys the ranged-defense bonus restrictions. +- `none` is always available. + +The response may include a safe-integer `defenseModifier` with a required reason +when nonzero. The engine revalidates the selected option; the client cannot submit +an arbitrary defense kind or bonus. + +### Resolution order + +1. Validate the completed strike roll. +2. Apply natural-one and attack-kind minimum-total rules. +3. Resolve an authorized opposed defense with `resolveStrike`. +4. Determine critical state and multiplier. +5. Roll damage and add only the applicable flat damage bonus. +6. Apply the multiplier after the flat bonus. +7. Route a hit against current protection. +8. Return new pool values without mutating input. + +### S.D.C. routing + +Protection is classified as: + +- `none` — damage personal S.D.C., then Hit Points; +- `sdcArmor` — compare completed strike total to A.R.; or +- `mdcArmor` — return `unsupportedMdcProtection` before rolling. + +For nondepleted S.D.C. armor: + +- strike total at or below A.R. routes the entire hit to armor; +- strike total above A.R. routes the entire hit to the wearer; +- armor floors at zero; +- a destroying hit never spills to the wearer; and +- a future attack treats zero-point armor as no protection. + +The current production catalog contains no page-stamped S.D.C. armor suit. Pure +rules tests therefore exercise this branch with validated fixtures. The backend +uses the same generic path and will activate it without redesign when real content +exists. + +## Persisted exchange model + +A new `combatExchanges` table stores a queryable ledger. Its validator mirrors a +discriminated status model rather than a loose object with contradictory optional +fields. + +Shared fields include: + +- attacker and defender character IDs; +- attacker/defender display names captured for history; +- weapon index, item ID, display name, and expected instance snapshot; +- attack kind and damage type; +- GM context; +- attacker and defender combat-state tokens; +- completed server strike roll; +- status; and +- Convex creation time. + +Statuses are: + +- `pendingDefense` — a potential hit awaits a response; +- `resolved` — miss, defended attack, or applied hit with immutable details; +- `cancelled` — withdrawn before response; and +- `stale` — relevant character state changed before response. + +Resolved details include the chosen defense, completed defense roll when present, +critical state, detailed damage roll, total damage, routed pool, previous and next +pool values, and a stable outcome/reason code. + +Indexes support bounded queries for: + +- pending exchanges by defender; +- pending/outgoing exchanges by attacker; and +- recent exchanges involving a character. + +Queries always use hard limits. Records are not automatically deleted in this +slice; long-term retention belongs to the future encounter/history design. + +## Combat-state tokens + +The declaration stores deterministic tokens made from only combat-relevant state. +They are stale-state guards, not hashes for authorization or secrecy. + +The attacker token covers: + +- level, attributes, Hand-to-Hand type, and derived combat profile; +- selected weapon index and expected item instance; and +- any future structured proficiency data consumed by the attack profile. + +The defender token covers: + +- level, attributes, Hand-to-Hand type, and relevant defensive profile; +- rolled/current S.D.C. and Hit Points; +- worn armor identity, instance state, maximum, current pool, tier, and A.R.; and +- any future structured defensive data consumed by option derivation. + +Tokens use a stable serialization of an explicitly ordered value. Narrative, +P.P.E., unrelated inventory entries, and other noncombat changes do not stale an +exchange. + +If either token differs at response time, the mutation records `stale` and returns +without applying damage. Resolving one hit may intentionally stale another pending +hit against the same old pools. + +## Backend operations + +### `combatTargets` + +A bounded query returns other characters with only target-selection data: + +- ID and display name; +- whether vitals are rolled and combat-ready; and +- protection tier (`none`, `sdcArmor`, or `mdcArmor`). + +The query supports good disabled-state copy, but the declaration mutation repeats +all validation. Self-targeting is refused. + +### `declareAttack` + +1. Load and derive attacker and defender. +2. Resolve and verify the indexed weapon against the client's expected instance. +3. Derive the attack profile and defender protection. +4. Validate GM context and modifier reason. +5. Refuse unsupported M.D. or M.D.C. state before dice or inserts. +6. Compute combat-state tokens. +7. Roll the strike with Convex's replay-safe mutation randomness. +8. Record an immediate resolved miss or a pending defense. +9. Return the persisted exchange. + +Even a successful strike with no active defense option remains pending with “take +the hit” as the only response. This preserves defender/GM acknowledgement and keeps +all successful strikes on the same two-phase path. + +### `respondToAttack` + +1. Load the pending exchange; reject resolved/cancelled/stale state. +2. Reload and re-derive both characters. +3. Recompute tokens and mark stale on mismatch. +4. Recompute legal defenses and validate the selected response. +5. Roll the defense when selected. +6. Resolve the opposed strike. +7. On a hit, roll damage and route it through the pure engine. +8. Revalidate the new defender through `deriveSheet`. +9. Patch the defender and finalize the exchange in the same transaction. + +Convex mutation serialization ensures that only one concurrent response can win. +No mutation path leaves a finalized exchange without its matching pool update or a +pool update without its matching finalized exchange. + +### `cancelAttack` + +A pending exchange can be cancelled. Authentication is not yet present, so the +backend cannot enforce player-versus-GM ownership; that limitation is explicit and +belongs to the accounts/VTT authorization work. + +### Existing direct damage + +The current manual damage mutation remains a GM pool-adjustment utility and is not +called by hostile combat. The new exchange path never accepts client-routed damage. +Automated M.D.C. interaction remains refused even though a GM may continue to track +a displayed armor pool manually. + +## Web experience + +A new focused `CombatExchangePanel` lives at the top of the dossier's right-hand +command rail. It is separate from `SheetView` so the sheet component does not absorb +workflow, query, and mutation state. + +### Declare attack + +- Target selector excludes the current character. +- Weapon selector lists owned weapons and allows only supported S.D.C. weapons. +- M.D. weapons remain visible with a disabled follow-up explanation. +- Target readiness and M.D.C. protection appear before submission. +- Context controls are conditional: melee parry mode versus ranged range band, + plus awareness and optional reasoned modifier. +- The primary command reads `DECLARE ATTACK`, not `DEAL DAMAGE`. + +### Incoming attacks + +- Pending attacks show attacker, weapon, natural die, bonus, total, and context. +- The defender sees the completed strike before choosing, matching the printed + sequence. +- Only server-derived response options render as enabled commands. +- Each option shows its modifier and whether it consumes an action for manual + table tracking. +- “Take the hit” is explicit and never represented as an accidental default. + +### Recent exchanges + +A bounded persisted list shows: + +- miss, defended, hit, cancelled, or stale; +- strike and defense totals; +- critical state; +- damage formula/roll/total; +- armor/body routing; and +- the affected pool's remaining value. + +Local telemetry also receives concise machine-voice lines, but persisted history is +the cross-client source of truth. + +### Visual language and accessibility + +- Amber: prompts and unresolved machine state. +- Red: hostile hits and damage. +- Green: successful defense or safe completion. +- No cyan: this workflow is not magic. +- Existing notched panels, HUD typography, focus treatment, and compact density. +- Native labels for every selector/input, keyboard-operable defense commands, clear + disabled reasons, and live status/error announcements. + +## Navigation and asynchronous ownership + +Parameterized character routes do not remount. On route ID change, the combat panel +must: + +- reset target, weapon, context, response draft, errors, and expanded history; +- re-key incoming/outgoing/history queries; +- advance a monotonic ownership token; and +- ignore late mutation results unless both route ID and exchange ID still match. + +This follows the existing dossier rule: IDs and tokens own asynchronous work; +booleans do not. + +## Error handling + +Expected refusals use stable reason codes plus readable messages: + +- `selfTarget` +- `attackerNotReady` +- `defenderNotReady` +- `weaponMissingOrChanged` +- `unsupportedWeaponMode` +- `unsupportedMdWeapon` +- `unsupportedMdcProtection` +- `invalidContext` +- `modifierReasonRequired` +- `illegalDefense` +- `exchangeNotPending` +- `combatStateChanged` +- `characterMissing` + +Invalid declarations create no exchange. Invalid responses change neither exchange +nor character. State-token mismatch is different: it deliberately finalizes the +pending record as `stale` so every participant can see why it cannot resolve. + +## Testing strategy + +### Rules package + +Tests pin printed values and exercise: + +- melee 5+ and ranged 8+ strike thresholds; +- natural 1/natural 20 and defense tie behavior; +- weapon category and damage-tier classification; +- no P.P./general H2H leakage into firearm strike or damage; +- standard and bare-handed parry bonuses/action metadata; +- dodge and automatic-dodge capability/action metadata; +- ranged awareness and distance penalties; +- optional reasoned situational modifiers; +- critical multiplication after applicable melee damage bonus; +- A.R. equality, penetration, depleted armor, and no-spill destruction; +- pure body S.D.C.-then-Hit-Point damage; and +- refusal of M.D. weapons and M.D.C. protection. + +Pure exchange tests use injected RNG sequences so every branch has deterministic +strike, defense, and detailed damage assertions. + +### Backend + +Convex tests cover: + +- target summaries and self-exclusion; +- declaration validation and server rolls; +- immediate resolved misses; +- pending strikes and all legal response kinds; +- atomic hit plus exchange finalization; +- cancellation and immutable final states; +- concurrent responses where only one wins; +- attacker and defender token drift; +- unrelated narrative changes that do not stale combat; +- M.D.C. and unrolled-vitals refusal with no insert or pool change; and +- bounded incoming/outgoing/recent queries. + +Backend tests assert invariants from returned server rolls instead of accepting +client-supplied dice. Specific roll ordering remains proven in the pure injected-RNG +tests. + +### Web and live browser + +Automated web checks cover form filtering, disabled reasons, option presentation, +route resets, and stale/late-result guards where practical in the current web test +setup. + +Live verification uses at least two seeded characters and two dossiers: + +1. Roll both characters' vitals. +2. Give the attacker an S.D.C. melee weapon and firearm. +3. Declare a melee attack and respond from the defender dossier. +4. Repeat until miss, defense, and hit result presentation have been observed. +5. Verify the body pools and persisted history update together. +6. Navigate between dossiers while a request is in flight and verify no state/log + crosses ownership. +7. Equip M.D.C. armor on the defender and verify the declaration is disabled and + server-refused if called directly. + +The production catalog has no S.D.C. armor suit, so that routing branch is verified +in pure engine tests until valid page-stamped content activates it live. + +## Validation gates + +Before publication: + +```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 +``` + +The exact task aliases must be confirmed against repository configuration before +execution. Root gates do not replace affected-package gates. + +## Tracker and documentation delivery + +Implementation delivery includes: + +- correcting issue #44's natural-A.R. statement with rendered p.339 evidence; +- updating issue #44 with the approved S.D.C.-tier and persisted-exchange scope; +- creating a dedicated full-M.D.C. follow-up for conversion, impervious armor, + final-blast absorption, depleted protection, unarmored lethal outcomes, and any + required injury/death state; +- recording evidence-backed progress and verification totals; and +- opening a PR for Cubic review and human merge. + +Issue #44 is not complete merely because local code exists. It is complete only +when implementation, package/root gates, live browser verification, tracker state, +and the PR review are genuinely aligned. The human maintainer remains the merger. + +## Success criteria + +- An attacker can declare a supported S.D.C. weapon attack against another ready, + unprotected S.D.C. character. +- The strike is server-rolled and visible before the defender chooses. +- The defender sees only rules- and context-authorized responses and retains the + choice to take the hit. +- A valid response resolves exactly once and atomically updates history and the + correct target pool. +- Relevant state changes safely stale pending attacks; unrelated narrative edits do + not. +- Artificial S.D.C. armor routing is pure, tested, and ready for future page-stamped + content. +- M.D. weapons and M.D.C.-protected targets never enter S.D.C. arithmetic. +- No natural-A.R. behavior is added contrary to RUE p.339. +- Navigation cannot leak combat drafts, logs, or late results between characters. +- All affected package gates, root gates, and live-browser checks pass. From 07e75183930d37c35ad8100f39be2fe695ba4c44 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Mon, 20 Jul 2026 00:11:59 +0800 Subject: [PATCH 02/21] docs: plan S.D.C. combat exchange --- .../plans/2026-07-19-sdc-combat-exchange.md | 3167 +++++++++++++++++ 1 file changed, 3167 insertions(+) create mode 100644 .codex/superpowers/plans/2026-07-19-sdc-combat-exchange.md diff --git a/.codex/superpowers/plans/2026-07-19-sdc-combat-exchange.md b/.codex/superpowers/plans/2026-07-19-sdc-combat-exchange.md new file mode 100644 index 0000000..a08ce58 --- /dev/null +++ b/.codex/superpowers/plans/2026-07-19-sdc-combat-exchange.md @@ -0,0 +1,3167 @@ +# S.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 #44 as a rules-faithful, persisted, two-phase S.D.C. combat exchange: the server rolls a declared strike, the defender/GM chooses an authorized response after seeing it, and Convex resolves and applies the result atomically. + +**Architecture:** Add a page-stamped pure `combat-exchange` layer above the existing `resolveStrike` primitive, then expose it through a discriminated Convex exchange ledger and a focused SolidJS command-rail panel. The client submits choices and context only; the server reloads and re-derives both characters, owns all dice, verifies combat-state tokens, routes damage, and persists the character write plus immutable outcome in one mutation. + +**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 branch `feat/sdc-combat-exchange`; do not create a worktree. +- Treat `.codex/superpowers/specs/2026-07-19-sdc-combat-exchange-design.md` as the approved product and rules contract. +- Treat rendered RUE pages 287, 288, 339-341, 344-346, 355, and 360-361 as the rules authority. Do not add a Natural A.R. branch: rendered p.339 explicitly says Natural A.R. does not apply in Rifts. +- Keep this slice S.D.C.-only. Refuse M.D. weapons and nondepleted M.D.C. protection before rolling dice or inserting an exchange. Full M.D.C. resolution is a separate issue. +- Preserve the production item catalog as transcribed. It currently contains no S.D.C. armor, so exercise that generic route with validated pure fixtures instead of inventing content. +- Keep `resolveStrike(input)` as the low-level opposed-roll primitive. New orchestration belongs in `engine/combat-exchange.ts`. +- Keep rules functions pure and deterministic. They consume derived sheets, explicit context, completed rolls, and completed damage rolls; only Convex calls `Math.random` and writes state. +- Do not add initiative, rounds, action-budget enforcement, authentication/ownership, VTT geometry, cover, bursts, called shots, hit locations, thrown modes, unarmed attacks, or generic spell attacks. +- Keep the current `characters.applyDamage` mutation as a manual GM utility. The hostile exchange path must never accept a client-selected damage route or delegate to that public mutation. +- Use `ConvexError` data with stable codes for expected backend refusals. Do not make the web client parse English error strings to decide behavior. +- Query through indexes and hard limits. Use 50 for target discovery and 20 for each pending/recent ledger feed. +- Parameterized character routes do not remount. Reset all combat drafts on route-ID change and accept an async result only when its captured route epoch and exchange ID still own it. +- Use Vite+ commands, not plain Vite. In `packages/backend`, use `pnpm exec`, never `npx`. +- Follow red -> green -> refactor for each production change. Observe every named focused test fail before implementing it. +- Checkpoint-commit after every task. Do not add AI attribution to commits, issues, comments, or PRs. Never commit to `main`, merge the PR, or close #44 manually. +- Run affected-package gates before root gates. Root `vp check` does not replace the per-package CI gates. + +### Frontend direction + +- **Visual thesis:** A compact hostile-action console embedded in the existing Ley Terminal right rail: amber for unresolved machine prompts, blood red for applied harm, green for a legal defense or settled safe state, and no ley cyan because combat is not magic. +- **Content plan:** `DECLARE ATTACK` is the primary work surface, `INCOMING` is the response queue, `OUTGOING` is the cancel/awaiting queue, and `RECENT` is the bounded persisted audit trail. Each section has one action and uses dividers rather than a dashboard-card mosaic. +- **Interaction thesis:** Reuse the existing short strike-flash for newly resolved results, preserve the telemetry cursor for ambient live state, and keep form/route transitions immediate and restrained. Add no animation library and no decorative motion. +- Follow `DESIGN.md`: notched geometry, current type system, compact 4px spacing rhythm, native labels, amber focus, no rounded corners, no painted imagery, and no cyan in this feature. + +--- + +## File Map + +### New files + +- `packages/rules/src/schema/combat-exchange.ts` - attack/context/response enums, reasoned-modifier validation, error-code vocabulary, and page-stamped exchange-rule schema. +- `packages/rules/src/content/combat/combat-exchange.json` - rendered-page citations, melee/ranged target totals, and ranged dodge penalties. +- `packages/rules/src/engine/combat-exchange.ts` - attack profiles, authorized defenses, protection classification, pure resolution/routing, and deterministic combat-state tokens. +- `packages/rules/tests/combat-exchange.test.ts` - the full pure truth table, including future-ready S.D.C. armor fixtures. +- `packages/backend/convex/character-state.ts` - shared character load/validate/patch and expected-item helpers extracted without behavior changes. +- `packages/backend/convex/combat-values.ts` - reusable Convex validators for contexts, rolls, response options, and the discriminated ledger document. +- `packages/backend/convex/combat.ts` - bounded combat queries plus declare/respond/cancel mutations. +- `packages/backend/tests/combat.test.ts` - Convex ledger, mutation, atomicity, stale-token, and boundary tests. +- `apps/web/src/lib/combat-exchange.ts` - pure selector/presentation helpers and async ownership-token helpers. +- `apps/web/tests/combat-exchange.test.ts` - weapon/target presentation, history formatting, tones, and ownership tests. +- `apps/web/src/components/combat-exchange-panel.tsx` - the declare/incoming/outgoing/recent command-rail workflow. + +### Modified files + +- `packages/rules/src/engine/combat.ts` - expose explicit Hand-to-Hand and automatic-dodge capability plus ranged-defense totals that exclude ordinary H2H dodge. +- `packages/rules/src/engine/character.ts` - project the additive combat-profile fields onto the sheet. +- `packages/rules/src/index.ts` - export the new schema, content boundary, engine types, and helpers. +- `packages/rules/tests/combat.test.ts` - pin capability and ranged-defense derivation. +- `packages/rules/tests/character.test.ts` - update the complete sheet projection. +- `packages/backend/convex/characters.ts` - consume the extracted shared state helpers. +- `packages/backend/convex/schema.ts` - register `combatExchanges` and its four indexes. +- `packages/backend/convex/_generated/api.d.ts` - generated `combat` module reference. +- `packages/backend/convex/_generated/dataModel.d.ts` - regenerated model derived from the expanded schema. +- `apps/web/src/components/ui.tsx` - add a design-system-consistent native select primitive. +- `apps/web/src/components/telemetry-rail.tsx` - make the telemetry body a section inside the new single right-rail landmark. +- `apps/web/src/pages/character-sheet.tsx` - mount the combat panel above telemetry actions and forward concise telemetry lines. +- `README.md` - replace the now-stale statement that A.R./hostile persistence remain entirely future work, while retaining the explicit M.D.C. boundary. +- `.codex/superpowers/specs/2026-07-19-sdc-combat-exchange-design.md` - record implementation and verification outcome after all gates and live checks pass. +- `.codex/superpowers/plans/2026-07-19-sdc-combat-exchange.md` - check completed steps and add final time-scoped evidence during execution. + +--- + +### Task 1: Page-Stamped Exchange Constants and Explicit Combat Capabilities + +**Files:** + +- Create: `packages/rules/src/schema/combat-exchange.ts` +- Create: `packages/rules/src/content/combat/combat-exchange.json` +- Modify: `packages/rules/src/engine/combat.ts` +- Modify: `packages/rules/src/engine/character.ts` +- Modify: `packages/rules/src/index.ts` +- Modify: `packages/rules/tests/combat.test.ts` +- Modify: `packages/rules/tests/character.test.ts` +- Create: `packages/rules/tests/combat-exchange.test.ts` + +**Interfaces:** + +```ts +export const attackKindSchema = z.enum(["melee", "ranged"]); +export type AttackKind = z.infer; + +export const rangeBandSchema = z.enum(["pointBlank", "close", "normal"]); +export type RangeBand = z.infer; + +export const parryModeSchema = z.enum(["unavailable", "standard", "bareHanded"]); +export type ParryMode = z.infer; + +export const combatResponseKindSchema = z.enum(["parry", "dodge", "autoDodge", "none"]); +export type CombatResponseKind = z.infer; + +export const combatExchangeErrorCodeSchema = z.enum([ + "selfTarget", + "attackerNotReady", + "defenderNotReady", + "weaponMissingOrChanged", + "unsupportedWeaponMode", + "unsupportedMdWeapon", + "unsupportedMdcProtection", + "invalidContext", + "modifierReasonRequired", + "illegalDefense", + "exchangeNotPending", + "combatStateChanged", + "characterMissing", +]); +export type CombatExchangeErrorCode = z.infer; + +export const combatExchangeRulesSchema = z.object({ + book: z.string().min(1), + pages: z.object({ + armorAndVitals: z.literal(287), + sdcCombat: z.literal(339), + defense: z.literal(340), + damage: z.literal(341), + automaticDodge: z.literal(344), + modernWeapons: z.literal(360), + rangedDodging: z.literal(361), + }), + minimumStrikeTotal: z.object({ melee: z.literal(5), ranged: z.literal(8) }), + rangedDodgeModifier: z.object({ + pointBlank: z.literal(-10), + close: z.literal(-5), + normal: z.literal(0), + }), +}); +export type CombatExchangeRules = z.infer; +``` + +Extend `CombatProfile` additively with: + +```ts +handToHandType: string; +hasHandToHandTraining: boolean; +hasAutoDodge: boolean; +rangedDodge: number; +rangedAutoDodge: number; +``` + +`rangedDodge` is the P.P. dodge bonus only. `rangedAutoDodge` is that same P.P. bonus when the character has automatic-dodge capability, otherwise zero. Neither includes ordinary H2H dodge nor the H2H `autoDodge` modifier; this preserves the p.361 ranged restriction. Future structured O.C.C. bonuses can be added at this named seam. + +- [ ] **Step 1: Add failing constants and combat-profile assertions** + +Create `packages/rules/tests/combat-exchange.test.ts` with the constants assertion: + +```ts +import { describe, expect, test } from "vite-plus/test"; +import { combatExchangeRules } from "../src/index.ts"; + +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, + }, + minimumStrikeTotal: { melee: 5, ranged: 8 }, + rangedDodgeModifier: { pointBlank: -10, close: -5, normal: 0 }, + }); + }); +}); +``` + +Append to `packages/rules/tests/combat.test.ts`: + +```ts +test("makes training and ranged-defense capability explicit", () => { + const untrained = combatProfile({ attributes: { PP: 20 }, hthType: "none", level: 1 }); + expect(untrained).toMatchObject({ + handToHandType: "none", + hasHandToHandTraining: false, + hasAutoDodge: false, + rangedDodge: 3, + rangedAutoDodge: 0, + }); + + const commando = combatProfile({ attributes: { PP: 20 }, hthType: "commando", level: 15 }); + expect(commando).toMatchObject({ + handToHandType: "commando", + hasHandToHandTraining: true, + hasAutoDodge: true, + dodge: 7, + autoDodge: 8, + rangedDodge: 3, + rangedAutoDodge: 3, + }); +}); +``` + +Update the exact object assertion in `packages/rules/tests/character.test.ts` to require the five new fields for the level-1 Basic H2H sheet. + +- [ ] **Step 2: Run the focused tests and observe the missing exports/fields** + +Run: + +```text +vp test packages/rules/tests/combat-exchange.test.ts packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts +``` + +Expected: FAIL because `combatExchangeRules` and the five profile fields do not exist. + +- [ ] **Step 3: Add the schema and page-stamped content** + +Create `packages/rules/src/content/combat/combat-exchange.json` exactly as asserted above. Add the enums and `combatExchangeRulesSchema` to `packages/rules/src/schema/combat-exchange.ts`. Parse the JSON at module load in the initial `packages/rules/src/engine/combat-exchange.ts`: + +```ts +import combatExchangeRaw from "../content/combat/combat-exchange.json" with { type: "json" }; +import { combatExchangeRulesSchema } from "../schema/combat-exchange.ts"; + +export const combatExchangeRules = combatExchangeRulesSchema.parse(combatExchangeRaw); +``` + +- [ ] **Step 4: Derive the explicit profile fields** + +In `combatProfile`, calculate the capability booleans from the H2H ID/raw bonus and return: + +```ts +const hasAutoDodge = hth.autoDodge !== undefined; +const rangedDodge = attr.dodge ?? 0; +``` + +Add `handToHandType: input.hthType`, `hasHandToHandTraining`, `hasAutoDodge`, +`rangedDodge`, and `rangedAutoDodge: hasAutoDodge ? rangedDodge : 0` to the +existing returned object without removing or redefining any current field. + +Do not remove or redefine the existing `dodge` and `autoDodge` totals. + +- [ ] **Step 5: Export the new public surface and pass the focused tests** + +Add to `packages/rules/src/index.ts`: + +```ts +export * from "./schema/combat-exchange.ts"; +export * from "./engine/combat-exchange.ts"; +``` + +Run the Step 2 command again. Expected: PASS. + +- [ ] **Step 6: Run the rules gate and commit** + +Run: + +```text +vp run @riftforge/rules#check +vp run @riftforge/rules#test +``` + +Expected: PASS. + +Commit: + +```text +git add -- packages/rules/src/schema/combat-exchange.ts packages/rules/src/content/combat/combat-exchange.json packages/rules/src/engine/combat-exchange.ts packages/rules/src/engine/combat.ts packages/rules/src/engine/character.ts packages/rules/src/index.ts packages/rules/tests/combat-exchange.test.ts packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts +git commit -m "feat(rules): model S.D.C. combat capabilities" +``` + +--- + +### Task 2: Reasoned Context Validation and Weapon Attack Profiles + +**Files:** + +- Modify: `packages/rules/src/schema/combat-exchange.ts` +- Modify: `packages/rules/src/engine/combat-exchange.ts` +- Modify: `packages/rules/tests/combat-exchange.test.ts` + +**Interfaces:** + +```ts +export type CombatContext = + | { + kind: "melee"; + defenderAware: boolean; + parryMode: ParryMode; + strikeModifier?: number; + strikeModifierReason?: string; + } + | { + kind: "ranged"; + defenderAware: boolean; + rangeBand: RangeBand; + strikeModifier?: number; + strikeModifierReason?: string; + }; + +export interface ModifierSource { + source: "attribute" | "handToHand" | "proficiency"; + label: string; + value: number; +} + +export interface WeaponInstanceSnapshot { + index: number; + itemId: string; + worn?: boolean; + rolledMdc?: number; +} + +export type AttackProfile = + | { + supported: false; + reason: "weaponMissingOrChanged" | "unsupportedWeaponMode" | "unsupportedMdWeapon"; + weapon?: WeaponInstanceSnapshot & { name: string }; + } + | { + supported: true; + kind: AttackKind; + minimumStrikeTotal: number; + strikeBonus: number; + strikeBonusSources: ModifierSource[]; + proficiencyBonus: number; + damageFormula: string; + damageBonus: number; + criticalOn: number; + damageType: "sdc"; + weapon: WeaponInstanceSnapshot & { name: string; category: WeaponCategory }; + }; + +export function deriveAttackProfile(sheet: CharacterSheet, weaponIndex: number): AttackProfile; +export function validateCombatContext( + profile: Extract, + input: unknown, +): CombatContext; +``` + +`combatContextSchema` is a discriminated Zod union. `strikeModifier` is optional and defaults semantically to zero; when nonzero it must be a safe integer from -100 through +100 and `strikeModifierReason` must contain non-whitespace text. Context kind must equal the server-derived attack kind. + +- [ ] **Step 1: Add failing profile and validation tests** + +Build sheets with `deriveSheet` and real catalog entries. Cover these exact cases: + +```ts +test("classifies real S.D.C. melee and firearm instances", () => { + const sheet = combatSheet({ + items: [{ itemId: "survival-knife" }, { itemId: "automatic-pistol" }], + }); + expect(deriveAttackProfile(sheet, 0)).toMatchObject({ + supported: true, + kind: "melee", + minimumStrikeTotal: 5, + strikeBonus: 3, + damageFormula: "1D6", + damageBonus: 1, + criticalOn: 20, + damageType: "sdc", + }); + expect(deriveAttackProfile(sheet, 1)).toMatchObject({ + supported: true, + kind: "ranged", + minimumStrikeTotal: 8, + strikeBonus: 0, + proficiencyBonus: 0, + damageFormula: "4D6", + damageBonus: 0, + criticalOn: 20, + damageType: "sdc", + }); +}); + +test("refuses M.D. weapons and non-weapons without inventing modes", () => { + const sheet = combatSheet({ + items: [{ itemId: "wilks-320-laser-pistol" }, { itemId: "canteen" }], + }); + expect(deriveAttackProfile(sheet, 0)).toMatchObject({ + supported: false, + reason: "unsupportedMdWeapon", + }); + expect(deriveAttackProfile(sheet, 1)).toMatchObject({ + supported: false, + reason: "unsupportedWeaponMode", + }); + expect(deriveAttackProfile(sheet, 99)).toEqual({ + supported: false, + reason: "weaponMissingOrChanged", + }); +}); + +test("requires reasons for nonzero GM modifiers and rejects kind mismatch", () => { + const profile = requireSupported( + deriveAttackProfile(combatSheet({ items: [{ itemId: "survival-knife" }] }), 0), + ); + expect(() => + validateCombatContext(profile, { + kind: "melee", + defenderAware: true, + parryMode: "standard", + strikeModifier: 1, + }), + ).toThrow(/reason/i); + expect(() => + validateCombatContext(profile, { + kind: "ranged", + defenderAware: true, + rangeBand: "normal", + }), + ).toThrow(/kind/i); +}); +``` + +The test-local `combatSheet` helper must use a complete valid level-1 Ley Line Walker input with rolled H.P./S.D.C.; it must not cast an arbitrary object to `CharacterSheet`. + +- [ ] **Step 2: Run the focused test and observe missing functions** + +Run: `vp test packages/rules/tests/combat-exchange.test.ts` + +Expected: FAIL because attack-profile and context functions are missing. + +- [ ] **Step 3: Implement schemas and the category/tier classifier** + +Map `knife` and `axe` to `melee`; map `handgun`, `submachineGun`, `energyPistol`, and `energyRifle` to `ranged`; then refuse any weapon whose damage type is `md`. Do not infer a thrown mode from a knife or axe. + +For melee sources, record P.P. strike and ordinary H2H strike separately from `sheet.attributeBonuses` and `sheet.combat.handToHandBonuses`. For ranged sources, include only the specifically named `strikeGuns` H2H value plus a typed proficiency source fixed at zero. Sum the sources to the returned `strikeBonus`. + +Add this implementation to `engine/combat-exchange.ts` (with the interface +definitions from this task): + +```ts +const meleeCategories = new Set(["knife", "axe"]); + +function weaponSnapshot( + entry: SheetEquipmentEntry, + index: number, +): WeaponInstanceSnapshot & { name: string } { + return { + index, + itemId: entry.item.id, + name: entry.item.name, + ...(entry.worn === true ? { worn: true } : {}), + ...(entry.rolledMdc === undefined ? {} : { rolledMdc: entry.rolledMdc }), + }; +} + +export function deriveAttackProfile(sheet: CharacterSheet, weaponIndex: number): AttackProfile { + const entry = Number.isInteger(weaponIndex) ? sheet.equipment[weaponIndex] : undefined; + if (entry === undefined) return { supported: false, reason: "weaponMissingOrChanged" }; + const snapshot = weaponSnapshot(entry, weaponIndex); + 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" + ? [ + { source: "attribute", label: "P.P.", value: sheet.attributeBonuses.strike ?? 0 }, + { + source: "handToHand", + label: "Hand-to-Hand", + value: sheet.combat.handToHandBonuses.strike ?? 0, + }, + ] + : [ + { + source: "handToHand", + label: "Gun-specific Hand-to-Hand", + value: sheet.combat.handToHandBonuses.strikeGuns ?? 0, + }, + { source: "proficiency", label: "Modern W.P.", value: 0 }, + ]; + return { + supported: true, + kind, + minimumStrikeTotal: combatExchangeRules.minimumStrikeTotal[kind], + strikeBonus: strikeBonusSources.reduce((sum, source) => sum + source.value, 0), + strikeBonusSources, + proficiencyBonus: 0, + damageFormula: entry.item.damage.formula, + damageBonus: kind === "melee" ? sheet.combat.damageBonus : 0, + criticalOn: kind === "melee" ? sheet.combat.criticalStrikeOn : 20, + damageType: "sdc", + weapon: { ...snapshot, category: entry.item.category }, + }; +} +``` + +- [ ] **Step 4: Implement context normalization** + +Parse with the discriminated schema, compare `context.kind` to `profile.kind`, normalize omitted modifiers to zero at use sites, and keep the submitted reason for history. Reject non-safe integers, values outside -100..100, and blank required reasons. + +Add to `schema/combat-exchange.ts`: + +```ts +const safeModifierSchema = z.number().int().safe().min(-100).max(100).optional(); +const modifierReasonSchema = z.string().trim().min(1).optional(); +const rawCombatContextSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("melee"), + defenderAware: z.boolean(), + parryMode: parryModeSchema, + strikeModifier: safeModifierSchema, + strikeModifierReason: modifierReasonSchema, + }), + z.object({ + kind: z.literal("ranged"), + defenderAware: z.boolean(), + rangeBand: rangeBandSchema, + strikeModifier: safeModifierSchema, + strikeModifierReason: modifierReasonSchema, + }), +]); + +export const combatContextSchema = rawCombatContextSchema.superRefine((context, check) => { + if ((context.strikeModifier ?? 0) !== 0 && context.strikeModifierReason === undefined) { + check.addIssue({ + code: "custom", + path: ["strikeModifierReason"], + message: "A reason is required for a nonzero strike modifier.", + }); + } +}); +export type CombatContext = z.infer; +``` + +Add to `engine/combat-exchange.ts`: + +```ts +export function validateCombatContext( + profile: Extract, + input: unknown, +): CombatContext { + const context = combatContextSchema.parse(input); + if (context.kind !== profile.kind) { + throw new Error( + `invalidContext: ${context.kind} context cannot resolve ${profile.kind} attack.`, + ); + } + return context; +} +``` + +- [ ] **Step 5: Pass focused/package tests and commit** + +Run: + +```text +vp test packages/rules/tests/combat-exchange.test.ts +vp run @riftforge/rules#check +vp run @riftforge/rules#test +``` + +Expected: 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): derive supported S.D.C. attacks" +``` + +--- + +### Task 3: Engine-Authorized Defense Options + +**Files:** + +- Modify: `packages/rules/src/schema/combat-exchange.ts` +- Modify: `packages/rules/src/engine/combat-exchange.ts` +- Modify: `packages/rules/tests/combat-exchange.test.ts` + +**Interfaces:** + +```ts +export interface DefenseOption { + kind: CombatResponseKind; + bonus: number; + actionCost: 0 | 1; + explanation: string; +} + +export interface CombatResponseInput { + kind: CombatResponseKind; + defenseModifier?: number; + defenseModifierReason?: string; +} + +export interface AuthorizedCombatResponse extends DefenseOption { + defenseModifier: number; + defenseModifierReason?: string; + totalBonus: number; +} + +export function deriveDefenseOptions( + defender: CharacterSheet, + attack: Extract, + context: CombatContext, +): DefenseOption[]; + +export function authorizeCombatResponse( + options: readonly DefenseOption[], + input: unknown, +): AuthorizedCombatResponse; +``` + +- [ ] **Step 1: Add the failing defense truth table** + +Cover all approved rules in `packages/rules/tests/combat-exchange.test.ts`: + +- aware melee + standard parry: `parry`, `dodge`, and `none`; +- Basic H2H parry action cost 0; `hthType: "none"` parry action cost 1; +- bare-handed weapon parry bonus 0; +- unaware melee: no ordinary parry/dodge, but a Commando with explicit capability retains `autoDodge` plus `none`; +- aware ranged: `dodge`, capability-gated `autoDodge`, and `none`, never parry; +- unaware ranged: `none` only; +- point-blank/close/normal penalties are -10/-5/0 and apply to ranged dodge-family options; +- ranged defense uses `rangedDodge`/`rangedAutoDodge`, never ordinary H2H dodge/autoDodge; +- `none` is always last, has bonus 0/action cost 0, and is never an implicit default; +- response modifiers obey the same safe-integer/reason rule; +- submitting an option not present in the derived list throws `illegalDefense`. + +- [ ] **Step 2: Run the focused test and observe missing defense functions** + +Run: `vp test packages/rules/tests/combat-exchange.test.ts` + +Expected: FAIL on missing exports. + +- [ ] **Step 3: Implement deterministic option enumeration** + +Use this ordering so UI and persisted history remain stable: + +```ts +const options: DefenseOption[] = []; +// melee: parry, dodge, autoDodge when authorized +// ranged: dodge, autoDodge when authorized +options.push({ kind: "none", bonus: 0, actionCost: 0, explanation: "Take the hit." }); +return options; +``` + +Implement the complete branch between initialization and the final push as: + +```ts +if (attack.kind === "melee") { + if (context.kind !== "melee") + throw new Error("invalidContext: melee attack requires melee context."); + if (context.defenderAware && context.parryMode !== "unavailable") { + options.push({ + kind: "parry", + bonus: context.parryMode === "bareHanded" ? 0 : defender.combat.parry, + actionCost: defender.combat.hasHandToHandTraining ? 0 : 1, + explanation: + context.parryMode === "bareHanded" + ? "Bare-handed weapon parry; no ordinary parry bonus." + : "Parry the melee weapon.", + }); + } + if (context.defenderAware) { + options.push({ + kind: "dodge", + bonus: defender.combat.dodge, + actionCost: 1, + explanation: "Dodge; costs one action for table tracking.", + }); + } + if (defender.combat.hasAutoDodge) { + options.push({ + kind: "autoDodge", + bonus: defender.combat.autoDodge, + actionCost: 0, + explanation: "Automatic dodge; no action cost.", + }); + } +} else { + if (context.kind !== "ranged") + throw new Error("invalidContext: ranged attack requires ranged context."); + if (context.defenderAware) { + const rangeModifier = combatExchangeRules.rangedDodgeModifier[context.rangeBand]; + options.push({ + kind: "dodge", + bonus: defender.combat.rangedDodge + rangeModifier, + actionCost: 1, + explanation: `Ranged dodge (${rangeModifier} range-band modifier).`, + }); + if (defender.combat.hasAutoDodge) { + options.push({ + kind: "autoDodge", + bonus: defender.combat.rangedAutoDodge + rangeModifier, + actionCost: 0, + explanation: `Automatic ranged dodge (${rangeModifier} range-band modifier).`, + }); + } + } +} +``` + +Melee automatic dodge does not require `defenderAware`; ranged dodge-family options do. Standard parry uses `sheet.combat.parry`; bare-handed parry uses zero. Ordinary dodge costs one action, automatic dodge costs zero, and a trained standard parry costs zero while an untrained standard parry records one. + +- [ ] **Step 4: Authorize the submitted response against the derived list** + +Parse the response schema, find the exact `kind` in the server-derived options, then return its server-owned bonus/action metadata plus the normalized situational modifier. Never accept a bonus or action cost from the caller. + +Add the schema and implementation: + +```ts +const rawCombatResponseInputSchema = z.object({ + kind: combatResponseKindSchema, + defenseModifier: safeModifierSchema, + defenseModifierReason: modifierReasonSchema, +}); +export const combatResponseInputSchema = rawCombatResponseInputSchema.superRefine( + (response, check) => { + if ((response.defenseModifier ?? 0) !== 0 && response.defenseModifierReason === undefined) { + check.addIssue({ + code: "custom", + path: ["defenseModifierReason"], + message: "A reason is required for a nonzero defense modifier.", + }); + } + }, +); +export type CombatResponseInput = z.infer; +``` + +```ts +export function authorizeCombatResponse( + options: readonly DefenseOption[], + input: unknown, +): AuthorizedCombatResponse { + const response = combatResponseInputSchema.parse(input); + const option = options.find((candidate) => candidate.kind === response.kind); + if (option === undefined) throw new Error(`illegalDefense: ${response.kind} is not authorized.`); + const defenseModifier = response.defenseModifier ?? 0; + return { + ...option, + defenseModifier, + ...(response.defenseModifierReason === undefined + ? {} + : { defenseModifierReason: response.defenseModifierReason }), + totalBonus: option.bonus + defenseModifier, + }; +} +``` + +- [ ] **Step 5: Pass focused/package tests and commit** + +Run: + +```text +vp test packages/rules/tests/combat-exchange.test.ts +vp run @riftforge/rules#check +vp run @riftforge/rules#test +``` + +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): derive combat defense choices" +``` + +--- + +### Task 4: Pure Strike Completion, S.D.C. Routing, and Damage Results + +**Files:** + +- Modify: `packages/rules/src/engine/combat-exchange.ts` +- Modify: `packages/rules/tests/combat-exchange.test.ts` + +**Interfaces:** + +```ts +export type ProtectionState = + | { kind: "none" } + | { kind: "sdcArmor"; itemId: string; name: string; ar: number; max: number; current: number } + | { kind: "mdcArmor"; itemId: string; name: string; max?: number; current?: number }; + +export type DeclarationResult = + | { status: "miss"; reason: "naturalOne" | "belowMinimum" } + | { status: "pendingDefense" }; + +export type SdcDamageRoute = + | { + kind: "armor"; + armor: { before: number; after: number }; + body: { before: VitalsPool; after: VitalsPool }; + } + | { + kind: "body"; + armor?: { before: number; after: number }; + body: { before: VitalsPool; after: VitalsPool }; + } + | { kind: "unsupportedMdcProtection" }; + +export type CombatExchangeResolution = + | { outcome: "miss"; reason: "naturalOne" | "belowMinimum"; critical: false; damageMultiplier: 1 } + | { + outcome: "defended"; + reason: "parried" | "dodged"; + response: AuthorizedCombatResponse; + defenseRoll: D20Roll; + critical: false; + damageMultiplier: 1; + } + | { + outcome: "hit"; + reason: "unopposed" | "strikeWon"; + response: AuthorizedCombatResponse; + defenseRoll?: D20Roll; + critical: boolean; + damageMultiplier: 1 | 2; + damageRoll: DamageRoll; + totalDamage: number; + route: Exclude; + }; + +export function deriveProtection(sheet: CharacterSheet): ProtectionState; +export function evaluateDeclaration(strike: D20Roll, minimumStrikeTotal: number): DeclarationResult; +export function routeSdcHit(input: { + strikeTotal: number; + damage: number; + protection: ProtectionState; + body: VitalsPool; + comaDeathFloor: number; +}): SdcDamageRoute; +export interface ResolveCombatExchangeInput { + attack: Extract; + context: CombatContext; + strikeRoll: D20Roll; + response: AuthorizedCombatResponse; + defenseRoll?: D20Roll; + damageRoll?: DamageRoll; + protection: ProtectionState; + body: VitalsPool; + comaDeathFloor: number; +} +export function resolveCombatExchange(input: ResolveCombatExchangeInput): CombatExchangeResolution; +``` + +- [ ] **Step 1: Add failing declaration and protection tests** + +Assert melee total 4 misses/5 proceeds, ranged total 7 misses/8 proceeds, natural 1 misses, and invalid completed rolls are rejected through the existing resolver validation path. + +Derive `none` for an unarmored sheet and `mdcArmor` for a worn production suit. +The M.D.C. variant keeps `max`/`current` optional so a legacy dice-capacity suit +whose per-instance capacity has not been rolled is still safely classified and +refused. Directly test a validated S.D.C. armor fixture: + +```ts +const fixture = armorSchema.parse({ + kind: "armor", + id: "test-sdc-armor", + name: "Validated S.D.C. Armor Fixture", + ar: 12, + sdc: 30, + page: 287, +}); +const armor: ProtectionState = { + kind: "sdcArmor", + itemId: fixture.id, + name: fixture.name, + ar: fixture.ar!, + max: fixture.sdc!, + current: 5, +}; + +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 } }, +}); +``` + +Also assert strike 13 routes the full hit to the body without changing armor, depleted armor routes future hits to the body, armor destruction never spills, body S.D.C. depletes before H.P., and `mdcArmor` returns `unsupportedMdcProtection` without conversion. + +- [ ] **Step 2: Run the focused test and observe missing routing exports** + +Run: `vp test packages/rules/tests/combat-exchange.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement protection classification and routing** + +For worn S.D.C. armor, require its schema-guaranteed `ar` and `sdc`; use its derived current pool. Treat current zero as `none`. For worn M.D.C. armor, return `mdcArmor` while current is positive and `none` when depleted. + +Use `damageArmor` for the armor pool and existing `applyDamage` for the body. Never carry armor overflow into the body. + +```ts +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, + name: armor.item.name, + ...(armor.max === undefined ? {} : { max: armor.max }), + ...(armor.current === undefined ? {} : { current: armor.current }), + }; + } + const max = armor.item.sdc!; + const current = armor.current ?? max; + return current === 0 + ? { kind: "none" } + : { + kind: "sdcArmor", + itemId: armor.item.id, + name: armor.item.name, + ar: armor.item.ar!, + max, + current, + }; +} + +export function evaluateDeclaration( + strike: D20Roll, + minimumStrikeTotal: number, +): DeclarationResult { + const base = resolveStrike({ strike, allowedDefenses: [], damageType: "sdc" }); + if (base.outcome === "miss") { + return { status: "miss", reason: base.reason as "naturalOne" | "belowMinimum" }; + } + return strike.total < minimumStrikeTotal + ? { status: "miss", reason: "belowMinimum" } + : { status: "pendingDefense" }; +} + +export function routeSdcHit(input: { + strikeTotal: number; + damage: number; + protection: ProtectionState; + body: VitalsPool; + comaDeathFloor: number; +}): SdcDamageRoute { + if (input.protection.kind === "mdcArmor") return { kind: "unsupportedMdcProtection" }; + 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 } }, + }; + } + 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 }, + }; +} +``` + +- [ ] **Step 4: Add failing end-to-end pure resolution tests** + +Use completed `D20Roll` and `DamageRoll` objects to cover: + +- declaration miss creates no defense/damage branch; +- parry/dodge tie favors the defender; +- natural-20 defense beats natural-20 strike through `resolveStrike`; +- `none` resolves an unopposed hit; +- melee damage is `(formula total + melee damage bonus) * critical multiplier`; +- firearm damage has no P.S./H2H flat bonus; +- missing defense roll for a defense choice and extra defense roll for `none` are rejected; +- missing damage roll on a hit and supplied damage on a miss/defense are rejected; +- the supplied damage roll bonus must equal `attack.damageBonus`, its die count + and faces must match `attack.damageFormula`, and its total must be internally + consistent; +- M.D.C. protection is rejected before any S.D.C. route result. + +- [ ] **Step 5: Implement `resolveCombatExchange` over `resolveStrike`** + +Call `evaluateDeclaration` first. For a selected defense, require the completed defense roll and pass only its server-authorized kind to `resolveStrike`. For `none`, omit defense. Require the completed damage roll only after a hit. Multiply `damageRoll.total` after its flat bonus, then call `routeSdcHit`. + +Add this private validator, then implement the resolver: + +```ts +function assertDamageRoll( + attack: Extract, + roll: DamageRoll, +): void { + const formula = parseDice(attack.damageFormula); + if (roll.bonus !== attack.damageBonus) { + throw new Error(`Damage bonus must be ${attack.damageBonus}.`); + } + if (roll.dice.length !== formula.count) { + throw new Error(`Damage roll requires ${formula.count} dice.`); + } + if (roll.dice.some((die) => !Number.isInteger(die) || die < 1 || die > formula.sides)) { + throw new Error(`Damage dice must be integers from 1 to ${formula.sides}.`); + } + const expected = + roll.dice.reduce((sum, die) => sum + die, 0) * formula.multiplier + + formula.modifier + + roll.bonus; + if (roll.total !== expected) throw new Error(`Damage total must be ${expected}.`); +} +``` + +```ts +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}.`); + } + const declaration = evaluateDeclaration(input.strikeRoll, input.attack.minimumStrikeTotal); + if (declaration.status === "miss") { + if (input.defenseRoll !== undefined || input.damageRoll !== undefined) { + throw new Error("A missed declaration cannot contain defense or damage rolls."); + } + return { + outcome: "miss", + reason: declaration.reason, + critical: false, + damageMultiplier: 1, + }; + } + + const takesHit = input.response.kind === "none"; + if (takesHit && input.defenseRoll !== undefined) { + throw new Error("Take-the-hit cannot contain a defense roll."); + } + if (!takesHit && input.defenseRoll === undefined) { + throw new Error(`${input.response.kind} requires a completed defense roll.`); + } + if (input.defenseRoll !== undefined && input.defenseRoll.bonus !== input.response.totalBonus) { + throw new Error(`Defense bonus must be ${input.response.totalBonus}.`); + } + + const strike = resolveStrike({ + strike: input.strikeRoll, + ...(takesHit + ? {} + : { + defense: { + kind: input.response.kind as DefenseKind, + roll: input.defenseRoll!, + }, + }), + allowedDefenses: takesHit ? [] : [input.response.kind as DefenseKind], + damageType: "sdc", + criticalOn: input.attack.criticalOn, + }); + if (strike.outcome !== "hit") { + if (input.damageRoll !== undefined) throw new Error("A defended attack cannot contain damage."); + return { + outcome: "defended", + reason: strike.reason as "parried" | "dodged", + response: input.response, + defenseRoll: input.defenseRoll!, + critical: false, + damageMultiplier: 1, + }; + } + if (input.damageRoll === undefined) throw new Error("A hit requires a completed damage roll."); + assertDamageRoll(input.attack, input.damageRoll); + const totalDamage = input.damageRoll.total * strike.damageMultiplier; + const route = routeSdcHit({ + strikeTotal: input.strikeRoll.total, + damage: 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", + response: input.response, + ...(input.defenseRoll === undefined ? {} : { defenseRoll: input.defenseRoll }), + critical: strike.critical, + damageMultiplier: strike.damageMultiplier, + damageRoll: input.damageRoll, + totalDamage, + route, + }; +} +``` + +- [ ] **Step 6: Pass focused/package tests and commit** + +Run: + +```text +vp test packages/rules/tests/combat-exchange.test.ts packages/rules/tests/strike-resolution.test.ts packages/rules/tests/items.test.ts packages/rules/tests/combat.test.ts +vp run @riftforge/rules#check +vp run @riftforge/rules#test +``` + +Commit: + +```text +git add -- packages/rules/src/engine/combat-exchange.ts packages/rules/tests/combat-exchange.test.ts +git commit -m "feat(rules): resolve and route S.D.C. exchanges" +``` + +--- + +### Task 5: Deterministic Combat-State Tokens + +**Files:** + +- Modify: `packages/rules/src/engine/combat-exchange.ts` +- Modify: `packages/rules/tests/combat-exchange.test.ts` + +**Interfaces:** + +```ts +export function attackerCombatStateToken(sheet: CharacterSheet, weaponIndex: number): string; +export function defenderCombatStateToken(sheet: CharacterSheet): string; +``` + +Tokens are stable JSON serialization of explicit ordered arrays, not arbitrary object serialization and not a security hash. + +- [ ] **Step 1: Add failing token-scope tests** + +Create two independently derived equivalent sheets and assert identical attacker/defender tokens. Then assert: + +- narrative edits, P.P.E. changes, and unrelated inventory additions do not change either relevant token; +- attacker level, attributes, H2H type/derived profile, selected weapon index, selected item ID, or selected instance state changes the attacker token; +- defender level, attributes, H2H type/defense profile, rolled/current S.D.C., rolled/current H.P., worn armor identity, maximum/current pool, tier, or A.R. changes the defender token; +- an unrelated attacker inventory entry does not stale the selected attack; +- token strings do not contain narrative/backstory text. + +- [ ] **Step 2: Run the focused test and observe missing token exports** + +Run: `vp test packages/rules/tests/combat-exchange.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement explicit ordered serialization** + +Use fixed attribute order `IQ, ME, MA, PS, PP, PE, PB, Spd`. The attacker tuple must contain level, attributes, the combat fields consumed by `deriveAttackProfile`, selected index, and selected instance snapshot. The defender tuple must contain level, attributes, the fields consumed by `deriveDefenseOptions`, rolled/current body pools, coma floor, and the complete derived worn-protection tuple. + +Do not serialize the whole sheet; doing so would make narrative and unrelated catalog growth stale live attacks. + +```ts +const attributeOrder = ["IQ", "ME", "MA", "PS", "PP", "PE", "PB", "Spd"] as const; +const orderedAttributes = (sheet: CharacterSheet) => + attributeOrder.map((code) => sheet.attributes[code]); + +export function attackerCombatStateToken(sheet: CharacterSheet, weaponIndex: number): string { + const entry = sheet.equipment[weaponIndex]; + return JSON.stringify([ + "attacker-v1", + sheet.level, + orderedAttributes(sheet), + [ + sheet.combat.handToHandType, + sheet.combat.strike, + sheet.combat.damageBonus, + sheet.combat.strikeGuns, + sheet.combat.criticalStrikeOn, + ], + weaponIndex, + entry === undefined ? null : [entry.item.id, entry.worn === true, entry.rolledMdc ?? null], + ]); +} + +export function defenderCombatStateToken(sheet: CharacterSheet): string { + return JSON.stringify([ + "defender-v1", + sheet.level, + orderedAttributes(sheet), + [ + sheet.combat.handToHandType, + sheet.combat.hasHandToHandTraining, + sheet.combat.hasAutoDodge, + sheet.combat.parry, + sheet.combat.dodge, + sheet.combat.autoDodge, + sheet.combat.rangedDodge, + sheet.combat.rangedAutoDodge, + ], + [ + sheet.vitals.sdc.rolled ?? null, + sheet.vitals.sdc.current ?? null, + sheet.vitals.hitPoints.rolled ?? null, + sheet.vitals.hitPoints.current ?? null, + sheet.vitals.comaDeathFloor, + ], + sheet.armor === undefined + ? null + : [ + sheet.armor.item.id, + sheet.armor.item.mdc === undefined ? "sdc" : "mdc", + sheet.armor.item.ar ?? null, + sheet.armor.max ?? null, + sheet.armor.current ?? null, + ], + ]); +} +``` + +- [ ] **Step 4: Pass focused/package tests and commit** + +Run: + +```text +vp test packages/rules/tests/combat-exchange.test.ts +vp run @riftforge/rules#check +vp run @riftforge/rules#test +``` + +Commit: + +```text +git add -- packages/rules/src/engine/combat-exchange.ts packages/rules/tests/combat-exchange.test.ts +git commit -m "feat(rules): add combat state tokens" +``` + +--- + +### Task 6: Extract Shared Backend Character-State Helpers + +**Files:** + +- Create: `packages/backend/convex/character-state.ts` +- Modify: `packages/backend/convex/characters.ts` +- Modify: `packages/backend/tests/characters.test.ts` + +**Interfaces:** + +Move, without semantic changes: + +```ts +export function validateCharacter(input: unknown): Character; +export async function loadCharacter(ctx: MutationCtx, id: Id<"characters">): Promise; +export async function patchCurrent( + ctx: MutationCtx, + id: Id<"characters">, + character: Character, + current: Character["current"], +): Promise; +export const expectedItemValidator = v.object({ + itemId: v.string(), + worn: v.optional(v.boolean()), + rolledMdc: v.optional(v.number()), +}); +export interface ExpectedItem { + itemId: string; + worn?: boolean; + rolledMdc?: number; +} +export function requireItemAt( + character: Character, + index: number, + expect: ExpectedItem, +): NonNullable[number]; +``` + +- [ ] **Step 1: Strengthen the stale-index regression before moving code** + +In `packages/backend/tests/characters.test.ts`, add an assertion that `requireItemAt` behavior exposed through `removeItem`/`equipArmor` rejects when the same index now contains a different `itemId`, even if the index remains valid. + +- [ ] **Step 2: Run the focused backend test before refactoring** + +Run: `vp test packages/backend/tests/characters.test.ts` + +Expected: PASS, proving the behavior being preserved. + +- [ ] **Step 3: Move helpers and update imports** + +Create `character-state.ts`, move the five helpers/types, and import them into `characters.ts`. Keep validation through `characterSchema`/`deriveSheet` and all current error messages unchanged. Do not export public Convex functions from the helper file. + +The new helper file is: + +```ts +import { characterSchema, deriveSheet, type Character } from "@riftforge/rules"; +import { v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx } from "./_generated/server"; + +export function validateCharacter(input: unknown): Character { + const character = characterSchema.parse(input); + deriveSheet(character); + return character; +} + +export async function loadCharacter(ctx: MutationCtx, id: Id<"characters">): Promise { + const doc = await ctx.db.get(id); + if (doc === null) throw new Error(`Character ${id} not found.`); + const { _id, _creationTime, ...stored } = doc; + return characterSchema.parse(stored); +} + +export async function patchCurrent( + ctx: MutationCtx, + id: Id<"characters">, + character: Character, + current: Character["current"], +): Promise { + validateCharacter({ ...character, current }); + await ctx.db.patch(id, { current }); +} + +export const expectedItemValidator = v.object({ + itemId: v.string(), + worn: v.optional(v.boolean()), + rolledMdc: v.optional(v.number()), +}); +export type ExpectedItem = { itemId: string; worn?: boolean; rolledMdc?: number }; + +export function requireItemAt( + character: Character, + index: number, + expect: ExpectedItem, +): Character["items"][number] { + const entry = Number.isInteger(index) ? character.items[index] : undefined; + if (entry === undefined) throw new Error(`No item at index ${index}.`); + if ( + entry.itemId !== expect.itemId || + (entry.worn === true) !== (expect.worn === true) || + entry.rolledMdc !== expect.rolledMdc + ) { + throw new Error("The manifest changed while the request was in flight — try again."); + } + return entry; +} +``` + +Delete these definitions from `characters.ts` and import exactly: + +```ts +import { + expectedItemValidator, + loadCharacter, + patchCurrent, + requireItemAt, + validateCharacter, +} from "./character-state"; +``` + +- [ ] **Step 4: Run backend regression gates** + +Run: + +```text +vp test packages/backend/tests/characters.test.ts packages/backend/tests/healing-cast.test.ts +vp run @riftforge/backend#check +vp run @riftforge/backend#test +``` + +Expected: PASS with no behavior change. + +- [ ] **Step 5: Commit the extraction** + +```text +git add -- packages/backend/convex/character-state.ts packages/backend/convex/characters.ts packages/backend/tests/characters.test.ts +git commit -m "refactor(backend): share character state helpers" +``` + +--- + +### Task 7: Discriminated Exchange Ledger and Bounded Read Queries + +**Files:** + +- Create: `packages/backend/convex/combat-values.ts` +- Create: `packages/backend/convex/combat.ts` +- Modify: `packages/backend/convex/schema.ts` +- Modify: `packages/backend/convex/_generated/api.d.ts` +- Modify: `packages/backend/convex/_generated/dataModel.d.ts` +- Create: `packages/backend/tests/combat.test.ts` + +**Ledger contract:** + +Every variant repeats these shared top-level fields so `status` remains indexable and the document remains a true discriminated union: + +```ts +const exchangeBase = { + attackerId: v.id("characters"), + defenderId: v.id("characters"), + attackerName: v.string(), + defenderName: v.string(), + weapon: weaponSnapshotValidator, + attack: attackSnapshotValidator, + context: combatContextValidator, + attackerStateToken: v.string(), + defenderStateToken: v.string(), + strikeRoll: d20RollValidator, +}; + +export const combatExchangeValidator = v.union( + v.object({ + ...exchangeBase, + status: v.literal("pendingDefense"), + defenseOptions: v.array(defenseOptionValidator), + }), + v.object({ + ...exchangeBase, + status: v.literal("resolved"), + resolution: resolvedResultValidator, + }), + v.object({ + ...exchangeBase, + status: v.literal("cancelled"), + cancelledAt: v.number(), + }), + v.object({ + ...exchangeBase, + status: v.literal("stale"), + staleAt: v.number(), + reason: v.literal("combatStateChanged"), + }), +); +``` + +`resolvedResultValidator` must itself be a union of `miss`, `defended`, and `hit` objects. Only the hit variant may contain damage/route data; only the defended variant requires a defense roll; the miss variant contains neither. The hit route is a union of armor and body snapshots, matching the pure rules result. + +Register: + +```ts +combatExchanges: defineTable(combatExchangeValidator) + .index("by_defender_and_status", ["defenderId", "status"]) + .index("by_attacker_and_status", ["attackerId", "status"]) + .index("by_defender", ["defenderId"]) + .index("by_attacker", ["attackerId"]), +``` + +**Public queries:** + +```ts +export const targets = query({ args: { attackerId: v.id("characters") }, handler }); +export const incoming = query({ args: { defenderId: v.id("characters") }, handler }); +export const outgoing = query({ args: { attackerId: v.id("characters") }, handler }); +export const recent = query({ args: { characterId: v.id("characters") }, handler }); +``` + +- [ ] **Step 1: Add failing target/query tests** + +Create `packages/backend/tests/combat.test.ts` using the established `convexTest(schema, modules)` pattern. Assert: + +- `targets` excludes the attacker and returns at most 50 entries; +- each target contains ID/name, `ready`, protection kind, and a stable disabled reason; +- unrolled vitals report `defenderNotReady`; +- nondepleted worn production armor reports `mdcArmor`/`unsupportedMdcProtection`; +- depleted worn M.D.C. armor reports `none` and is not disabled for protection; +- incoming/outgoing/recent are empty initially and bounded at 20. + +- [ ] **Step 2: Run the focused test and observe the missing API/schema failure** + +Run: `vp test packages/backend/tests/combat.test.ts` + +Expected: FAIL because `api.combat` and `combatExchanges` do not exist. + +- [ ] **Step 3: Add the validators, table, and read-only query module** + +Mirror rule-layer unions with Convex validators; do not use `v.any()`. In `targets`, take 50 characters, exclude self, derive each sheet, and map only the selector-safe summary. In the ledger queries, use the matching index plus `.take(20)`. For `recent`, take 20 from `by_attacker` and 20 from `by_defender`, merge/deduplicate by `_id`, sort by `_creationTime` descending, and slice to 20. + +Define these primitives in `combat-values.ts`; compose every ledger variant from +them rather than weakening the schema: + +```ts +import { v } from "convex/values"; + +export const d20RollValidator = v.object({ + die: v.number(), + bonus: v.number(), + total: v.number(), + target: v.optional(v.number()), + success: v.optional(v.boolean()), + naturalTwenty: v.boolean(), + naturalOne: v.boolean(), +}); +export const damageRollValidator = v.object({ + dice: v.array(v.number()), + total: v.number(), + bonus: v.number(), +}); +export const combatContextValidator = v.union( + v.object({ + kind: v.literal("melee"), + defenderAware: v.boolean(), + parryMode: v.union(v.literal("unavailable"), v.literal("standard"), v.literal("bareHanded")), + strikeModifier: v.optional(v.number()), + strikeModifierReason: v.optional(v.string()), + }), + v.object({ + kind: v.literal("ranged"), + defenderAware: v.boolean(), + rangeBand: v.union(v.literal("pointBlank"), v.literal("close"), v.literal("normal")), + strikeModifier: v.optional(v.number()), + strikeModifierReason: v.optional(v.string()), + }), +); +export const combatResponseInputValidator = v.object({ + kind: v.union(v.literal("parry"), v.literal("dodge"), v.literal("autoDodge"), v.literal("none")), + defenseModifier: v.optional(v.number()), + defenseModifierReason: v.optional(v.string()), +}); +export const defenseOptionValidator = v.object({ + kind: v.union(v.literal("parry"), v.literal("dodge"), v.literal("autoDodge"), v.literal("none")), + bonus: v.number(), + actionCost: v.union(v.literal(0), v.literal(1)), + explanation: v.string(), +}); +const authorizedResponseValidator = v.object({ + kind: v.union(v.literal("parry"), v.literal("dodge"), v.literal("autoDodge"), v.literal("none")), + bonus: v.number(), + actionCost: v.union(v.literal(0), v.literal(1)), + explanation: v.string(), + defenseModifier: v.number(), + defenseModifierReason: v.optional(v.string()), + totalBonus: v.number(), +}); +const weaponCategoryValidator = v.union( + v.literal("knife"), + v.literal("axe"), + v.literal("handgun"), + v.literal("submachineGun"), + v.literal("energyPistol"), + v.literal("energyRifle"), +); +const modifierSourceValidator = v.object({ + source: v.union(v.literal("attribute"), v.literal("handToHand"), v.literal("proficiency")), + label: v.string(), + value: v.number(), +}); +const exchangeBase = { + attackerId: v.id("characters"), + defenderId: v.id("characters"), + attackerName: v.string(), + defenderName: v.string(), + weapon: v.object({ + index: v.number(), + itemId: v.string(), + name: v.string(), + category: weaponCategoryValidator, + worn: v.optional(v.boolean()), + rolledMdc: v.optional(v.number()), + }), + attack: v.object({ + kind: v.union(v.literal("melee"), v.literal("ranged")), + minimumStrikeTotal: v.number(), + strikeBonus: v.number(), + strikeBonusSources: v.array(modifierSourceValidator), + proficiencyBonus: v.number(), + damageFormula: v.string(), + damageBonus: v.number(), + criticalOn: v.number(), + damageType: v.literal("sdc"), + }), + context: combatContextValidator, + attackerStateToken: v.string(), + defenderStateToken: v.string(), + strikeRoll: d20RollValidator, +}; +const routeValidator = 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() }), + }), + }), + 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() }), + }), + }), +); +export const resolvedResultValidator = v.union( + v.object({ + outcome: v.literal("miss"), + reason: v.union(v.literal("naturalOne"), v.literal("belowMinimum")), + critical: v.literal(false), + damageMultiplier: v.literal(1), + }), + v.object({ + outcome: v.literal("defended"), + reason: v.union(v.literal("parried"), v.literal("dodged")), + response: authorizedResponseValidator, + defenseRoll: d20RollValidator, + critical: v.literal(false), + damageMultiplier: v.literal(1), + }), + v.object({ + outcome: v.literal("hit"), + reason: v.union(v.literal("unopposed"), v.literal("strikeWon")), + response: authorizedResponseValidator, + defenseRoll: v.optional(d20RollValidator), + critical: v.boolean(), + damageMultiplier: v.union(v.literal(1), v.literal(2)), + damageRoll: damageRollValidator, + totalDamage: v.number(), + route: routeValidator, + }), +); +export const combatExchangeValidator = v.union( + v.object({ + ...exchangeBase, + status: v.literal("pendingDefense"), + defenseOptions: v.array(defenseOptionValidator), + }), + v.object({ + ...exchangeBase, + status: v.literal("resolved"), + resolution: resolvedResultValidator, + }), + v.object({ + ...exchangeBase, + status: v.literal("cancelled"), + cancelledAt: v.number(), + }), + v.object({ + ...exchangeBase, + status: v.literal("stale"), + staleAt: v.number(), + reason: v.literal("combatStateChanged"), + }), +); +``` + +Register the table in `schema.ts` with the exact four indexes shown in the +ledger contract. Add these read handlers to `combat.ts`: + +```ts +const TARGET_LIMIT = 50; +const FEED_LIMIT = 20; + +export const targets = query({ + args: { attackerId: v.id("characters") }, + handler: async (ctx, { attackerId }) => { + const docs = await ctx.db.query("characters").order("desc").take(TARGET_LIMIT); + return docs + .filter((doc) => doc._id !== attackerId) + .map((doc) => { + const sheet = deriveSheet(doc); + const ready = + sheet.vitals.sdc.rolled !== undefined && sheet.vitals.hitPoints.rolled !== undefined; + const protection = deriveProtection(sheet); + return { + id: doc._id, + name: sheet.name, + ready, + protection: protection.kind, + ...(ready + ? protection.kind === "mdcArmor" + ? { disabledReason: "unsupportedMdcProtection" as const } + : {} + : { disabledReason: "defenderNotReady" as const }), + }; + }); + }, +}); + +export const incoming = query({ + args: { defenderId: v.id("characters") }, + handler: (ctx, { defenderId }) => + ctx.db + .query("combatExchanges") + .withIndex("by_defender_and_status", (q) => + q.eq("defenderId", defenderId).eq("status", "pendingDefense"), + ) + .order("desc") + .take(FEED_LIMIT), +}); + +export const outgoing = query({ + args: { attackerId: v.id("characters") }, + handler: (ctx, { attackerId }) => + ctx.db + .query("combatExchanges") + .withIndex("by_attacker_and_status", (q) => + q.eq("attackerId", attackerId).eq("status", "pendingDefense"), + ) + .order("desc") + .take(FEED_LIMIT), +}); + +export const recent = query({ + args: { characterId: v.id("characters") }, + handler: async (ctx, { characterId }) => { + const [attacks, defenses] = await Promise.all([ + ctx.db + .query("combatExchanges") + .withIndex("by_attacker", (q) => q.eq("attackerId", characterId)) + .order("desc") + .take(FEED_LIMIT), + ctx.db + .query("combatExchanges") + .withIndex("by_defender", (q) => q.eq("defenderId", characterId)) + .order("desc") + .take(FEED_LIMIT), + ]); + return [...new Map([...attacks, ...defenses].map((doc) => [doc._id, doc])).values()] + .sort((a, b) => b._creationTime - a._creationTime) + .slice(0, FEED_LIMIT); + }, +}); +``` + +- [ ] **Step 4: Regenerate Convex types with pnpm** + +From `packages/backend`, run: + +```text +pnpm exec convex codegen +``` + +Expected: `api.d.ts` imports `../combat.js`; `dataModel.d.ts` remains generated from `schema.ts`. Do not follow the generated comment's `npx` wording. + +- [ ] **Step 5: Pass focused/package tests and commit** + +Run: + +```text +vp test packages/backend/tests/combat.test.ts +vp run @riftforge/backend#check +vp run @riftforge/backend#test +``` + +Commit: + +```text +git add -- packages/backend/convex/combat-values.ts packages/backend/convex/combat.ts packages/backend/convex/schema.ts packages/backend/convex/_generated/api.d.ts packages/backend/convex/_generated/dataModel.d.ts packages/backend/tests/combat.test.ts +git commit -m "feat(backend): add combat exchange ledger" +``` + +--- + +### Task 8: Server-Owned Attack Declaration + +**Files:** + +- Modify: `packages/backend/convex/combat.ts` +- Modify: `packages/backend/tests/combat.test.ts` + +**Mutation contract:** + +```ts +export const declareAttack = mutation({ + args: { + attackerId: v.id("characters"), + defenderId: v.id("characters"), + weaponIndex: v.number(), + expect: expectedItemValidator, + context: combatContextValidator, + }, + handler, +}); +``` + +Expected failures use: + +```ts +throw new ConvexError({ + code: "unsupportedMdWeapon", + message: "M.D. weapons require the full M.D.C. combat follow-up.", +}); +``` + +Use the complete approved code set: `selfTarget`, `attackerNotReady`, `defenderNotReady`, `weaponMissingOrChanged`, `unsupportedWeaponMode`, `unsupportedMdWeapon`, `unsupportedMdcProtection`, `invalidContext`, `modifierReasonRequired`, `illegalDefense`, `exchangeNotPending`, `combatStateChanged`, and `characterMissing`. + +- [ ] **Step 1: Add failing declaration tests** + +Assert: + +- self-targeting returns `selfTarget` and inserts nothing; +- attacker/defender must have rolled H.P. and S.D.C.; +- indexed weapon instance must match `expect`; +- gear/nonweapon returns `unsupportedWeaponMode`; +- M.D. weapon returns `unsupportedMdWeapon` before dice/insert; +- nondepleted M.D.C. target returns `unsupportedMdcProtection` before dice/insert; +- kind mismatch/invalid modifier/refusal inserts nothing; +- melee `strikeModifier: -100` with a reason guarantees an immediate persisted `resolved` miss; +- melee `strikeModifier: 100` with a reason guarantees `pendingDefense`; +- stored roll fields are internally consistent and no die/result is accepted from client args; +- a pending record captures names, exact weapon instance, attack/context snapshots, both tokens, and server-derived options including explicit `none`. + +- [ ] **Step 2: Run the focused test and observe the missing mutation** + +Run: `vp test packages/backend/tests/combat.test.ts` + +Expected: FAIL because `declareAttack` is missing. + +- [ ] **Step 3: Implement pre-roll validation in the approved order** + +1. Reject equal IDs. +2. Load and derive both characters. +3. Require rolled H.P./S.D.C. on both. +4. Verify the indexed item against `expect`. +5. Derive the attack profile and parse matching context. +6. Derive defender protection. +7. Reject M.D. weapon/M.D.C. protection. +8. Derive defense options and both state tokens. +9. Only now call `rollD20(attack.strikeBonus + contextModifier, attack.minimumStrikeTotal)`. +10. Insert a resolved miss or pending record and return it. + +Convert rules validation errors into stable `ConvexError` data at the backend boundary; do not expose raw Zod issue arrays to the UI. + +Implement the mutation with these helpers (imports come from +`@riftforge/rules`, `convex/values`, `./character-state`, and +`./combat-values`): + +```ts +function combatFailure(code: CombatExchangeErrorCode, message: string): never { + throw new ConvexError({ code, message }); +} + +function requireCombatReady( + sheet: CharacterSheet, + code: "attackerNotReady" | "defenderNotReady", +): void { + if (sheet.vitals.sdc.rolled === undefined || sheet.vitals.hitPoints.rolled === undefined) { + combatFailure(code, "Roll both S.D.C. and Hit Points before entering combat."); + } +} + +function parseDeclaredContext( + attack: Extract, + input: unknown, +): CombatContext { + const parsed = combatContextSchema.safeParse(input); + if (!parsed.success) { + const missingReason = parsed.error.issues.some((issue) => + issue.path.includes("strikeModifierReason"), + ); + combatFailure( + missingReason ? "modifierReasonRequired" : "invalidContext", + missingReason + ? "A reason is required for a nonzero strike modifier." + : "The declared combat context is invalid.", + ); + } + if (parsed.data.kind !== attack.kind) { + combatFailure("invalidContext", "The declared context does not match the weapon mode."); + } + return parsed.data; +} +``` + +```ts +export const declareAttack = mutation({ + args: { + attackerId: v.id("characters"), + defenderId: v.id("characters"), + weaponIndex: v.number(), + expect: expectedItemValidator, + context: combatContextValidator, + }, + handler: async (ctx, args) => { + if (args.attackerId === args.defenderId) { + combatFailure("selfTarget", "A character cannot target itself with a hostile exchange."); + } + let attacker: Character; + let defender: Character; + try { + [attacker, defender] = await Promise.all([ + loadCharacter(ctx, args.attackerId), + loadCharacter(ctx, args.defenderId), + ]); + } catch { + combatFailure("characterMissing", "The attacker or defender no longer exists."); + } + const attackerSheet = deriveSheet(attacker); + const defenderSheet = deriveSheet(defender); + requireCombatReady(attackerSheet, "attackerNotReady"); + requireCombatReady(defenderSheet, "defenderNotReady"); + try { + requireItemAt(attacker, args.weaponIndex, args.expect); + } catch { + combatFailure("weaponMissingOrChanged", "The selected weapon instance changed."); + } + 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.", + ); + } + 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 strikeModifier = context.strikeModifier ?? 0; + const strikeRoll = rollD20(attack.strikeBonus + strikeModifier, attack.minimumStrikeTotal); + const declaration = evaluateDeclaration(strikeRoll, attack.minimumStrikeTotal); + const { supported: _supported, weapon, ...attackSnapshot } = attack; + const base = { + attackerId: args.attackerId, + defenderId: args.defenderId, + attackerName: attackerSheet.name, + defenderName: defenderSheet.name, + weapon, + attack: attackSnapshot, + context, + attackerStateToken: attackerCombatStateToken(attackerSheet, args.weaponIndex), + defenderStateToken: defenderCombatStateToken(defenderSheet), + strikeRoll, + }; + const exchangeId = await ctx.db.insert( + "combatExchanges", + declaration.status === "miss" + ? { + ...base, + status: "resolved" as const, + resolution: { + outcome: "miss" as const, + reason: declaration.reason, + critical: false as const, + damageMultiplier: 1 as const, + }, + } + : { ...base, status: "pendingDefense" as const, defenseOptions }, + ); + return (await ctx.db.get(exchangeId))!; + }, +}); +``` + +- [ ] **Step 4: Pass focused/backend gates and commit** + +Run: + +```text +vp test packages/backend/tests/combat.test.ts +vp run @riftforge/backend#check +vp run @riftforge/backend#test +``` + +Commit: + +```text +git add -- packages/backend/convex/combat.ts packages/backend/tests/combat.test.ts +git commit -m "feat(backend): declare server-rolled attacks" +``` + +--- + +### Task 9: Atomic Response, Cancellation, and Stale-State Finalization + +**Files:** + +- Modify: `packages/backend/convex/combat.ts` +- Modify: `packages/backend/tests/combat.test.ts` + +**Mutation contracts:** + +```ts +export const respondToAttack = mutation({ + args: { + exchangeId: v.id("combatExchanges"), + response: combatResponseInputValidator, + }, + handler, +}); + +export const cancelAttack = mutation({ + args: { exchangeId: v.id("combatExchanges") }, + handler, +}); +``` + +- [ ] **Step 1: Add failing response/cancellation tests** + +Use `strikeModifier: 100` to guarantee pending declarations and assert: + +- `none` rolls damage server-side, resolves once, applies body S.D.C. before H.P., and stores matching before/after pools atomically; +- a melee defense with `defenseModifier: 100` guarantees a defended result and changes no pools; +- standard parry, bare-handed parry, dodge, and capability-gated auto dodge are accepted only when rederived options contain them; +- an illegal defense or missing modifier reason changes neither ledger nor character; +- `cancelAttack` changes pending to cancelled and no finalized state can respond/cancel again; +- two concurrent responses yield exactly one successful finalization and one `exchangeNotPending`, with damage applied once; +- changing attacker level/profile or selected weapon after declaration finalizes `stale` with no damage; +- changing defender body pools, defense profile, worn armor, or armor pool finalizes `stale` with no additional damage; +- updating either narrative does not stale; the response still resolves; +- unrelated P.P.E. or inventory state excluded by the token does not stale; +- resolving one hit intentionally stales a second pending hit made against the prior defender pools; +- response/cancel/query paths reject a missing character/exchange with the appropriate stable code. + +- [ ] **Step 2: Run the focused test and observe missing mutations** + +Run: `vp test packages/backend/tests/combat.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement stale check and response authorization before dice** + +Load the pending record, reload/rederive both characters, and compare both tokens. On mismatch, patch only the exchange to: + +```ts +{ ...exchange, status: "stale", staleAt: Date.now(), reason: "combatStateChanged" } +``` + +Return that record without rolling defense/damage or touching the defender. Otherwise rederive the attack profile, context, protection, and defense options, then authorize the submitted response. A client cannot replay the stored option bonus as authority. + +Use replacement, not patching, when changing the discriminated variant; otherwise +`defenseOptions` from the pending variant would survive as a contradictory field: + +```ts +function pendingBase(exchange: Doc<"combatExchanges">) { + if (exchange.status !== "pendingDefense") { + combatFailure("exchangeNotPending", "The combat exchange is no longer pending."); + } + const { _id, _creationTime, status: _status, defenseOptions: _options, ...base } = exchange; + return { id: _id, base }; +} + +function parseResponse(input: unknown): CombatResponseInput { + const parsed = combatResponseInputSchema.safeParse(input); + if (!parsed.success) { + const missingReason = parsed.error.issues.some((issue) => + issue.path.includes("defenseModifierReason"), + ); + combatFailure( + missingReason ? "modifierReasonRequired" : "illegalDefense", + missingReason + ? "A reason is required for a nonzero defense modifier." + : "The combat response is invalid.", + ); + } + return parsed.data; +} +``` + +- [ ] **Step 4: Implement server dice and the atomic character/result write** + +Roll defense only for `parry`/`dodge`/`autoDodge`, with target equal to the stored strike total. Run the pure resolver. Roll damage only if the result can hit, using `rollDamage(attack.damageFormula, attack.damageBonus)`; the pure resolver applies critical multiplication and routing. + +For a body route, patch current S.D.C./H.P.; for an armor route, patch only current armor. Re-run `deriveSheet` on the complete candidate character before `ctx.db.patch`. Finalize the exchange in the same mutation after the validated candidate is known. Convex transaction rollback must leave neither half committed if any later validation throws. + +```ts +export const respondToAttack = mutation({ + args: { + exchangeId: v.id("combatExchanges"), + response: combatResponseInputValidator, + }, + handler: async (ctx, args) => { + const stored = await ctx.db.get(args.exchangeId); + if (stored === null) { + combatFailure("exchangeNotPending", "The combat exchange no longer exists."); + } + const { id, base } = pendingBase(stored); + let attacker: Character; + let defender: Character; + try { + [attacker, defender] = await Promise.all([ + loadCharacter(ctx, stored.attackerId), + loadCharacter(ctx, stored.defenderId), + ]); + } catch { + combatFailure("characterMissing", "The attacker or defender no longer exists."); + } + const attackerSheet = deriveSheet(attacker); + const defenderSheet = deriveSheet(defender); + const tokensMatch = + attackerCombatStateToken(attackerSheet, stored.weapon.index) === stored.attackerStateToken && + defenderCombatStateToken(defenderSheet) === stored.defenderStateToken; + if (!tokensMatch) { + await ctx.db.replace(id, { + ...base, + status: "stale", + staleAt: Date.now(), + reason: "combatStateChanged", + }); + return (await ctx.db.get(id))!; + } + + const attack = deriveAttackProfile(attackerSheet, stored.weapon.index); + if (!attack.supported) { + combatFailure("combatStateChanged", "The stored attack profile can no longer be derived."); + } + const context = validateCombatContext(attack, stored.context); + const protection = deriveProtection(defenderSheet); + if (protection.kind === "mdcArmor") { + combatFailure("combatStateChanged", "The defender's protection changed."); + } + const options = deriveDefenseOptions(defenderSheet, attack, context); + const responseInput = parseResponse(args.response); + let response: AuthorizedCombatResponse; + try { + response = authorizeCombatResponse(options, responseInput); + } catch { + combatFailure("illegalDefense", "That defense is not legal for this exchange."); + } + const defenseRoll = + response.kind === "none" ? undefined : rollD20(response.totalBonus, stored.strikeRoll.total); + const opposed = resolveStrike({ + strike: stored.strikeRoll, + ...(defenseRoll === undefined + ? {} + : { defense: { kind: response.kind as DefenseKind, roll: defenseRoll } }), + allowedDefenses: defenseRoll === undefined ? [] : [response.kind as DefenseKind], + damageType: "sdc", + criticalOn: attack.criticalOn, + }); + const damageRoll = + opposed.outcome === "hit" ? rollDamage(attack.damageFormula, attack.damageBonus) : undefined; + const resolution = resolveCombatExchange({ + attack, + context, + strikeRoll: stored.strikeRoll, + response, + ...(defenseRoll === undefined ? {} : { defenseRoll }), + ...(damageRoll === undefined ? {} : { damageRoll }), + protection, + body: { + sdc: defenderSheet.vitals.sdc.current!, + hitPoints: defenderSheet.vitals.hitPoints.current!, + }, + comaDeathFloor: defenderSheet.vitals.comaDeathFloor, + }); + + 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); + } + await ctx.db.replace(id, { ...base, status: "resolved", resolution }); + return (await ctx.db.get(id))!; + }, +}); +``` + +- [ ] **Step 5: Implement pending-only cancellation** + +Reload by exchange ID, require `pendingDefense`, and patch the discriminated cancelled variant. Note in code that authorization is intentionally unavailable in this slice; do not simulate ownership with attacker/defender IDs supplied by the client. + +```ts +export const cancelAttack = mutation({ + args: { exchangeId: v.id("combatExchanges") }, + handler: async (ctx, { exchangeId }) => { + const stored = await ctx.db.get(exchangeId); + if (stored === null) { + combatFailure("exchangeNotPending", "The combat exchange no longer exists."); + } + const { id, base } = pendingBase(stored); + // Accounts/roles are not modeled yet; do not pretend an actor ID is authorization. + await ctx.db.replace(id, { ...base, status: "cancelled", cancelledAt: Date.now() }); + return (await ctx.db.get(id))!; + }, +}); +``` + +- [ ] **Step 6: Pass focused/backend gates and commit** + +Run: + +```text +vp test packages/backend/tests/combat.test.ts packages/backend/tests/characters.test.ts packages/backend/tests/healing-cast.test.ts +vp run @riftforge/backend#check +vp run @riftforge/backend#test +``` + +Commit: + +```text +git add -- packages/backend/convex/combat.ts packages/backend/tests/combat.test.ts +git commit -m "feat(backend): resolve combat exchanges atomically" +``` + +--- + +### Task 10: Pure Web Presentation and Ownership Guards + +**Files:** + +- Create: `apps/web/src/lib/combat-exchange.ts` +- Create: `apps/web/tests/combat-exchange.test.ts` + +**Interfaces:** + +```ts +export interface AsyncOwner { + routeId: string; + routeEpoch: number; + exchangeId?: string; +} + +export function ownsAsyncResult( + owner: AsyncOwner, + current: { routeId: string; routeEpoch: number; exchangeId?: string }, +): boolean; + +export function combatWeaponChoices(sheet: CharacterSheet): Array<{ + index: number; + itemId: string; + label: string; + supported: boolean; + disabledReason?: string; +}>; + +export function combatTargetDisabledReason(target: CombatTargetSummary): string | undefined; +export function exchangeTone(exchange: ExchangeSummary): "dim" | "warn" | "bad" | "good"; +export function formatExchangeSummary(exchange: ExchangeSummary): string; +export function combatErrorMessage(error: unknown): string; +``` + +Define the backend-return types without hand-copying them: + +```ts +type CombatTargets = FunctionReturnType; +export type CombatTargetSummary = CombatTargets[number]; +type CombatRecent = FunctionReturnType; +export type ExchangeSummary = CombatRecent[number]; +``` + +- [ ] **Step 1: Add failing pure web tests** + +Assert: + +- only owned weapons appear; all four S.D.C. weapons are enabled and M.D. weapons remain visible/disabled with full-M.D.C. copy; +- unready and M.D.C.-protected targets expose precise disabled copy; +- pending/stale/cancelled/miss/defended/hit tones map to amber/amber/muted/muted/green/red semantics without cyan; +- summaries include attacker/defender, weapon, strike, defense when present, critical, detailed damage, route, and remaining pool without fabricating absent fields; +- a result with a different route ID, epoch, or expected exchange ID is rejected; +- a current owner with all fields equal is accepted. + +- [ ] **Step 2: Run the focused test and observe missing module failure** + +Run: `vp test apps/web/tests/combat-exchange.test.ts` + +Expected: FAIL. + +- [ ] **Step 3: Implement structural helpers without duplicating rules math** + +`combatWeaponChoices` may call `deriveAttackProfile` for preview/support labels, but it must not calculate bonuses, thresholds, defenses, criticals, or A.R. itself. Formatting consumes server result fields verbatim. Keep helpers DOM-free so the current Node test environment remains sufficient. + +```ts +import { api } from "@riftforge/backend/api"; +import { deriveAttackProfile, type CharacterSheet } from "@riftforge/rules"; +import { ConvexError } from "convex/values"; +import type { FunctionReturnType } from "convex/server"; + +type CombatTargets = FunctionReturnType; +export type CombatTargetSummary = CombatTargets[number]; +type CombatRecent = FunctionReturnType; +export type ExchangeSummary = CombatRecent[number]; + +export interface AsyncOwner { + routeId: string; + routeEpoch: number; + exchangeId?: string; +} + +export function ownsAsyncResult(owner: AsyncOwner, current: AsyncOwner): boolean { + return ( + owner.routeId === current.routeId && + owner.routeEpoch === current.routeEpoch && + owner.exchangeId === current.exchangeId + ); +} + +export function combatWeaponChoices(sheet: CharacterSheet) { + return sheet.equipment.flatMap((entry, index) => { + if (entry.item.kind !== "weapon") return []; + const profile = deriveAttackProfile(sheet, index); + return [ + { + index, + itemId: entry.item.id, + label: `${entry.item.name} — ${entry.item.damage.formula} ${ + entry.item.damage.type === "md" ? "M.D." : "S.D.C." + }`, + supported: profile.supported, + ...(profile.supported + ? {} + : { + disabledReason: + profile.reason === "unsupportedMdWeapon" + ? "Full M.D.C. combat is follow-up work." + : "This weapon mode is not supported.", + }), + }, + ]; + }); +} + +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; +} + +export function exchangeTone(exchange: ExchangeSummary): "dim" | "warn" | "bad" | "good" { + 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"; + return "dim"; +} + +const signed = (value: number) => (value >= 0 ? `+${value}` : String(value)); + +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}`; + if (exchange.status === "pendingDefense") return `${lead} :: AWAITING DEFENSE`; + if (exchange.status === "cancelled") return `${lead} :: CANCELLED`; + if (exchange.status === "stale") return `${lead} :: STALE — COMBAT STATE CHANGED`; + const result = exchange.resolution; + if (result.outcome === "miss") return `${lead} :: MISS (${result.reason})`; + if (result.outcome === "defended") { + return `${lead} :: ${result.response.kind.toUpperCase()} d20[${result.defenseRoll.die}]${signed(result.defenseRoll.bonus)} = ${result.defenseRoll.total} :: DEFENDED`; + } + const damage = `[${result.damageRoll.dice.join("][")}]${signed(result.damageRoll.bonus)} = ${result.totalDamage} S.D.C.`; + const critical = result.critical ? " :: CRITICAL" : ""; + const remaining = + result.route.kind === "armor" + ? `ARMOR ${result.route.armor.after}` + : `BODY S.D.C. ${result.route.body.after.sdc} / H.P. ${result.route.body.after.hitPoints}`; + return `${lead}${critical} :: ${damage} → ${remaining}`; +} + +export function combatErrorMessage(error: unknown): string { + if ( + error instanceof ConvexError && + typeof error.data === "object" && + error.data !== null && + "message" in error.data && + typeof error.data.message === "string" + ) { + return error.data.message; + } + return error instanceof Error ? error.message : String(error); +} +``` + +- [ ] **Step 4: Pass focused/web gates and commit** + +Run: + +```text +vp test apps/web/tests/combat-exchange.test.ts apps/web/tests/convex.test.ts +vp run @riftforge/web#check +vp run @riftforge/web#test +``` + +Commit: + +```text +git add -- apps/web/src/lib/combat-exchange.ts apps/web/tests/combat-exchange.test.ts +git commit -m "test(web): define combat exchange presentation" +``` + +--- + +### Task 11: SolidJS Combat Exchange Command Panel + +**Files:** + +- Create: `apps/web/src/components/combat-exchange-panel.tsx` +- Modify: `apps/web/src/components/ui.tsx` +- Modify: `apps/web/src/components/telemetry-rail.tsx` +- Modify: `apps/web/src/pages/character-sheet.tsx` +- Modify: `apps/web/tests/combat-exchange.test.ts` + +**Component contract:** + +```ts +export interface CombatExchangePanelProps { + characterId: Id<"characters">; + sheet: CharacterSheet; + onTelemetry: (text: string, tone?: TelemetryTone) => void; +} + +export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Element; +``` + +- [ ] **Step 1: Add the native select primitive** + +Add `SelectInput` beside `TextInput` in `components/ui.tsx` using the same `notch-8`, border, noir background, mono text, amber focus, disabled-dead styling, and `ComponentProps<"select">` forwarding. No rounded corners and no new color tokens. + +```tsx +export function SelectInput(props: ComponentProps<"select">) { + const [own, rest] = splitProps(props, ["class", "children"]); + return ( + + ); +} +``` + +Move `ToggleChip` from `character-sheet.tsx` to `components/ui.tsx`, export it, +and import it back into the page as well as the new panel: + +```tsx +export function ToggleChip(props: { + pressed: boolean; + onToggle: () => void; + tone?: "ley" | "amber"; + children: JSX.Element; +}) { + return ( + + ); +} +``` + +- [ ] **Step 2: Build the declaration section** + +In `CombatExchangePanel`: + +- subscribe to `api.combat.targets`, `incoming`, `outgoing`, and `recent` with reactive character IDs; +- bind `declareAttack`, `respondToAttack`, and `cancelAttack` mutations; +- show target and owned-weapon native selects with visible disabled reasons; +- derive whether melee/ranged context controls render from the selected supported profile; +- provide an awareness toggle, melee parry-mode select or ranged range-band select, optional signed modifier, and reason input required by the form when nonzero; +- disable `DECLARE ATTACK` until both selector and context requirements are satisfied; +- submit attacker/defender IDs, index, exact item snapshot, and context only; +- never roll strike/damage client-side and never submit a damage route; +- announce success/failure through a visible `Alert` live region and one concise telemetry line. + +Use this state/query skeleton in the component; the selector data and profile math +come from the pure helper/rules package: + +```tsx +const targets = createQuery(convex, api.combat.targets, () => ({ + attackerId: props.characterId, +})); +const incoming = createQuery(convex, api.combat.incoming, () => ({ + defenderId: props.characterId, +})); +const outgoing = createQuery(convex, api.combat.outgoing, () => ({ + attackerId: props.characterId, +})); +const recent = createQuery(convex, api.combat.recent, () => ({ + characterId: props.characterId, +})); +const declareAttack = createMutation(convex, api.combat.declareAttack); +const [targetId, setTargetId] = createSignal(""); +const [weaponIndex, setWeaponIndex] = createSignal(""); +const [aware, setAware] = createSignal(true); +const [parryMode, setParryMode] = createSignal("standard"); +const [rangeBand, setRangeBand] = createSignal("normal"); +const [strikeModifier, setStrikeModifier] = createSignal(""); +const [strikeReason, setStrikeReason] = createSignal(""); +const [busy, setBusy] = createSignal(false); +const [error, setError] = createSignal(); +const [historyExpanded, setHistoryExpanded] = createSignal(false); +const choices = createMemo(() => combatWeaponChoices(props.sheet)); +const selectedIndex = createMemo(() => { + const value = Number(weaponIndex()); + return Number.isInteger(value) ? value : undefined; +}); +const selectedAttack = createMemo(() => { + const index = selectedIndex(); + return index === undefined ? undefined : deriveAttackProfile(props.sheet, index); +}); +const modifierValue = createMemo(() => { + const raw = strikeModifier().trim(); + if (raw === "") return 0; + const value = Number(raw); + return Number.isSafeInteger(value) && value >= -100 && value <= 100 ? value : undefined; +}); +const canDeclare = createMemo(() => { + const target = targets.data()?.find((candidate) => candidate.id === targetId()); + const attack = selectedAttack(); + const modifier = modifierValue(); + return ( + !busy() && + target !== undefined && + combatTargetDisabledReason(target) === undefined && + attack?.supported === true && + modifier !== undefined && + (modifier === 0 || strikeReason().trim() !== "") + ); +}); +``` + +The submit handler is: + +```tsx +const submitDeclaration = async () => { + const index = selectedIndex(); + const attack = selectedAttack(); + const modifier = modifierValue(); + const target = targetId() as Id<"characters">; + const entry = index === undefined ? undefined : props.sheet.equipment[index]; + if ( + !canDeclare() || + index === undefined || + !attack?.supported || + modifier === undefined || + !entry + ) { + return; + } + const owner = { routeId: props.characterId, routeEpoch }; + setBusy(true); + setError(undefined); + try { + const result = await declareAttack({ + attackerId: props.characterId, + defenderId: target, + weaponIndex: index, + expect: { + itemId: entry.item.id, + ...(entry.worn === true ? { worn: true } : {}), + ...(entry.rolledMdc === undefined ? {} : { rolledMdc: entry.rolledMdc }), + }, + context: + attack.kind === "melee" + ? { + kind: "melee", + defenderAware: aware(), + parryMode: parryMode(), + ...(modifier === 0 ? {} : { strikeModifier: modifier }), + ...(strikeReason().trim() === "" + ? {} + : { strikeModifierReason: strikeReason().trim() }), + } + : { + kind: "ranged", + defenderAware: aware(), + rangeBand: rangeBand(), + ...(modifier === 0 ? {} : { strikeModifier: modifier }), + ...(strikeReason().trim() === "" + ? {} + : { strikeModifierReason: strikeReason().trim() }), + }, + }); + if (!ownsAsyncResult(owner, { routeId: props.characterId, routeEpoch })) return; + props.onTelemetry( + `> COMBAT :: ${result.weapon.name.toUpperCase()} — ${result.status.toUpperCase()}`, + result.status === "resolved" ? "dim" : "machine", + ); + setStrikeModifier(""); + setStrikeReason(""); + } catch (caught) { + if (!ownsAsyncResult(owner, { routeId: props.characterId, routeEpoch })) return; + const message = combatErrorMessage(caught); + setError(message); + props.onTelemetry(`> COMBAT :: DECLARATION REFUSED — ${message}`, "bad"); + } finally { + if (ownsAsyncResult(owner, { routeId: props.characterId, routeEpoch })) setBusy(false); + } +}; +``` + +Render it inside a single `Panel` headed `// COMBAT EXCHANGE`. This is the exact +declaration form structure; keep the compact utility classes and do not add cards +around individual rows: + +```tsx + + COMBAT EXCHANGE +
+ + +
+ setAware((value) => !value)}> + AWARE + + + + + + + +
+
+ + +
+ + {(message) => {message()}} +
+
+``` + +- [ ] **Step 3: Build incoming and outgoing queues** + +Incoming rows show attacker, weapon, natural strike die, bonus, total, and stored context before any response action. Render only the stored server-derived response options as buttons, with modifier and `0 ACTION`/`1 ACTION` metadata. Render “TAKE THE HIT” explicitly for `none`. + +Each response form owns an optional modifier/reason. On submit, capture `{routeId, routeEpoch, exchangeId}` and ignore completion unless it still owns all three. Outgoing pending rows show target/weapon/strike and an explicit `CANCEL` command. + +Use keyed row components so draft state is scoped to an exchange. The response +handler must have this ownership and payload shape: + +```ts +type IncomingExchange = FunctionReturnType[number]; +type OutgoingExchange = FunctionReturnType[number]; +``` + +`IncomingExchangeRow` receives `exchange: IncomingExchange`, `characterId`, +`routeEpoch: Accessor`, and `onTelemetry`. It creates its own +`respondToAttack` mutation plus `defenseModifier`, `defenseReason`, `busy`, and +`error` signals. Its `defenseModifierValue` uses the same safe-integer -100..100 +parser as the declaration modifier. + +```tsx +const submitResponse = async (kind: CombatResponseKind) => { + const modifier = defenseModifierValue(); + if (modifier === undefined || (modifier !== 0 && defenseReason().trim() === "")) return; + const owner = { + routeId: props.characterId, + routeEpoch: props.routeEpoch(), + exchangeId: props.exchange._id, + }; + setBusy(true); + setError(undefined); + try { + const result = await respondToAttack({ + exchangeId: props.exchange._id, + response: { + kind, + ...(modifier === 0 ? {} : { defenseModifier: modifier }), + ...(defenseReason().trim() === "" ? {} : { defenseModifierReason: defenseReason().trim() }), + }, + }); + const current = { + routeId: props.characterId, + routeEpoch: props.routeEpoch(), + exchangeId: props.exchange._id, + }; + if (!ownsAsyncResult(owner, current)) return; + props.onTelemetry( + `> COMBAT :: ${result.status === "stale" ? "STALE" : "RESPONSE LOCKED"}`, + result.status === "stale" ? "dim" : "machine", + ); + } catch (caught) { + const current = { + routeId: props.characterId, + routeEpoch: props.routeEpoch(), + exchangeId: props.exchange._id, + }; + if (!ownsAsyncResult(owner, current)) return; + setError(combatErrorMessage(caught)); + } finally { + const current = { + routeId: props.characterId, + routeEpoch: props.routeEpoch(), + exchangeId: props.exchange._id, + }; + if (ownsAsyncResult(owner, current)) setBusy(false); + } +}; +``` + +Render options directly from `props.exchange.defenseOptions`: + +```tsx + + {(option) => ( + + )} + +``` + +The outgoing cancel handler captures the same owner triple, calls +`cancelAttack({ exchangeId })`, and emits `> COMBAT :: ATTACK CANCELLED` only if +the owner still matches. Render the strike die/bonus/total before both incoming +and outgoing actions. + +- [ ] **Step 4: Build recent persisted history** + +Use `formatExchangeSummary` and `exchangeTone`. Show at most 20 entries; include miss, defended, hit, cancelled, and stale states. Newly observed resolved IDs may receive the existing `strike-flash` class once, but do not animate the whole rail or add an animation dependency. + +```tsx +
+ + RECENT + + 0} + fallback={

// NO EXCHANGES

} + > +
    + + {(exchange) => ( +
  1. + {formatExchangeSummary(exchange)} +
  2. + )} +
    +
+
+
+``` + +Define `toneClass` locally as dim -> `border-dead text-muted`, warn -> +`border-amber text-amber`, bad -> `border-blood text-blood-text`, and good -> +`border-ok text-ok`. There is intentionally no cyan mapping. + +```ts +const toneClass = { + dim: "border-dead text-muted", + warn: "border-amber text-amber", + bad: "border-blood text-blood-text", + good: "border-ok text-ok", +} as const; +``` + +- [ ] **Step 5: Implement route reset and async ownership** + +Implement a named `resetRouteState` callback and wire it with +`createEffect(on(() => props.characterId, resetRouteState, { defer: true }))`. +The callback increments a monotonic route epoch and resets target, weapon, +context, modifiers, response draft/error state, and expanded history. Mutation +completion handlers must check the captured route ID and epoch; response +handlers also check exchange ID. A boolean `busy` may control button affordance, +but it is never sufficient for ownership. + +```tsx +let routeEpoch = 0; +const resetRouteState = () => { + routeEpoch += 1; + setTargetId(""); + setWeaponIndex(""); + setAware(true); + setParryMode("standard"); + setRangeBand("normal"); + setStrikeModifier(""); + setStrikeReason(""); + setBusy(false); + setError(undefined); + setHistoryExpanded(false); +}; +createEffect(on(() => props.characterId, resetRouteState, { defer: true })); +``` + +- [ ] **Step 6: Mount the panel at the top of the right rail** + +Change `TelemetryRail`'s root from ` diff --git a/apps/web/tests/combat-exchange.test.ts b/apps/web/tests/combat-exchange.test.ts index 5c53e1a..7da33fe 100644 --- a/apps/web/tests/combat-exchange.test.ts +++ b/apps/web/tests/combat-exchange.test.ts @@ -1,5 +1,8 @@ +/// + import { deriveSheet, type CharacterInput } from "@riftforge/rules"; import { ConvexError } from "convex/values"; +import { existsSync, readFileSync } from "node:fs"; import { describe, expect, test } from "vite-plus/test"; import { combatErrorMessage, @@ -12,6 +15,16 @@ import { type ExchangeSummary, } from "../src/lib/combat-exchange.ts"; +function source(relative: string): string { + const url = new URL(relative, import.meta.url); + return existsSync(url) ? readFileSync(url, "utf8") : ""; +} + +const panelSource = source("../src/components/combat-exchange-panel.tsx"); +const uiSource = source("../src/components/ui.tsx"); +const telemetryRailSource = source("../src/components/telemetry-rail.tsx"); +const characterSheetSource = source("../src/pages/character-sheet.tsx"); + const combatant: CharacterInput = { name: "Vesper", occId: "ley-line-walker", @@ -370,3 +383,79 @@ describe("combat errors", () => { expect(combatErrorMessage("Unknown combat error.")).toBe("Unknown combat error."); }); }); + +describe("combat exchange component contract", () => { + 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"); + expect(uiSource).toContain("focus:border-amber disabled:text-dead"); + expect(uiSource).toContain("export function ToggleChip"); + expect(characterSheetSource).not.toContain("function ToggleChip("); + }); + + test("subscribes every queue reactively and keeps strike, damage, and routing server-owned", () => { + expect(panelSource).toContain("api.combat.targets"); + expect(panelSource).toContain("api.combat.incoming"); + expect(panelSource).toContain("api.combat.outgoing"); + expect(panelSource).toContain("api.combat.recent"); + expect(panelSource).toContain("api.combat.declareAttack"); + expect(panelSource).toContain("api.combat.respondToAttack"); + expect(panelSource).toContain("api.combat.cancelAttack"); + expect(panelSource).not.toMatch(/\brollD20\b|\brollDamage\b|\bresolveCombatExchange\b/); + const declarationStart = panelSource.indexOf("const result = await declareAttack({"); + const declarationEnd = panelSource.indexOf("\n });", declarationStart); + expect(declarationStart).toBeGreaterThanOrEqual(0); + expect(declarationEnd).toBeGreaterThan(declarationStart); + expect(panelSource.slice(declarationStart, declarationEnd)).not.toMatch( + /strikeRoll\s*:|damageRoll\s*:|route\s*:/, + ); + }); + + test("renders stored context before only the stored server response options", () => { + expect(panelSource).toContain("STORED CONTEXT"); + expect(panelSource).toContain("props.exchange.context.defenderAware"); + expect(panelSource).toContain("props.exchange.defenseOptions"); + expect(panelSource.indexOf("STORED CONTEXT")).toBeLessThan( + panelSource.indexOf("props.exchange.defenseOptions"), + ); + expect(panelSource).toContain('option.kind === "none" ? "> TAKE THE HIT"'); + expect(panelSource).toContain("option.actionCost} ACTION"); + }); + + test("resets route state and gates declaration, response, and cancellation by ownership", () => { + expect(panelSource).toContain("const resetRouteState = () => {"); + expect(panelSource).toContain("routeEpoch += 1"); + expect(panelSource).toContain( + "createEffect(on(() => props.characterId, resetRouteState, { defer: true }))", + ); + expect(panelSource).toContain("routeId: props.characterId"); + expect(panelSource).toContain("routeEpoch: props.routeEpoch()"); + expect(panelSource).toContain("exchangeId: props.exchange._id"); + expect(panelSource).toContain("ownsAsyncResult(owner, current)"); + }); + + test("keeps persisted combat history bounded and uses no magic signal tone", () => { + expect(panelSource).toContain("formatExchangeSummary(exchange)"); + expect(panelSource).toContain("exchangeTone(exchange)"); + expect(panelSource).toContain("recent.data()?.slice(0, 20)"); + expect(panelSource).toContain('dim: "border-dead text-muted"'); + expect(panelSource).toContain('warn: "border-amber text-amber"'); + expect(panelSource).toContain('bad: "border-blood text-blood-text"'); + expect(panelSource).toContain('good: "border-ok text-ok"'); + expect(panelSource).not.toMatch(/border-ley|text-ley/); + }); + + test("mounts combat above telemetry under one complementary rail landmark", () => { + expect(characterSheetSource).toContain('aria-label="Dossier command rail"'); + expect(characterSheetSource).toContain("', + ); + expect(telemetryRailSource).not.toContain( + '