diff --git a/DEATH_SAVES_PLAN.md b/DEATH_SAVES_PLAN.md new file mode 100644 index 0000000..1b39cca --- /dev/null +++ b/DEATH_SAVES_PLAN.md @@ -0,0 +1,288 @@ +# Death Saves 5e - Implementation Plan + +TDD required. Tests first, implementation after. + +## Gap Analysis + +**Current:** +- ✅ Separate success/fail counters (deathSaves, deathFails) +- ✅ 3 success → stable, 3 fail → dead +- ✅ UI shows ✓/✕ buttons, status badges + +**Missing (from DEATH_SAVES.md):** +- ❌ Nat 20: 1 HP + conscious, reset counters +- ❌ Nat 1: 2 failures +- ❌ Damage at 0 HP → add failures (normal=1, crit=2) +- ❌ Massive damage: damage >= currentHp + maxHp → instant dead +- ❌ Stabilize manual action +- ❌ Healing resets counters +- ❌ Damage breaks stable → back to dying + +**Test Gap:** +- turn.deathsave.test.js: basic counters only +- turn.combat.test.js: single deathSave call, no coverage +- Combat.scenario.test.js: basic fail/death flow +- No nat20, nat1, damage-at-0, massive damage, stabilize, healing integration + +## Phase 1: Extend Tests (TDD) + +### File: `shared/tests/turn.deathsave.nat20.test.js` + +Test cases: +- Nat20 → hp=1, deathSaves=0, deathFails=0, isDying=false, isStable=false, status="conscious" +- Nat20 overrides existing saves/fails +- Nat20 log entry correct + +### File: `shared/tests/turn.deathsave.nat1.test.js` + +Test cases: +- Nat1 → deathFails += 2 +- Nat1 triggers instant death (3 fails total) +- Nat1 log entry correct + +### File: `shared/tests/turn.deathsave.damage.test.js` + +Test cases: +- Damage at 0 HP → deathFails += 1 +- Crit damage at 0 HP → deathFails += 2 +- Mixed: save success + damage → independent tracking works +- Damage log includes "while dying" + +### File: `shared/tests/turn.deathsave.massive.test.js` + +Test cases: +- Damage >= currentHp + maxHp → instant dead +- Bypasses death saves entirely +- Log entry shows "massive damage" + +### File: `shared/tests/turn.deathsave.stabilize.test.js` + +Test cases: +- Stabilize → hp=0, isStable=true, isDying=false, counters=0 +- Stabilize from dying state +- Stabilize log entry correct + +### File: `shared/tests/turn.deathsave.heal.test.js` + +Test cases: +- Healing at 0 HP → reset counters, isDying=false, isStable=false +- Healing at stable → same reset +- Healing log includes "cleared death saves" + +### File: `shared/tests/turn.deathsave.stable.test.js` + +Test cases: +- Damage to stable → isStable=false, isDying=true, counters reset +- Stable takes damage → fresh death save loop + +### File: `shared/tests/turn.deathsave.undo.test.js` + +Test cases: +- Undo deathSave restores previous state +- Undo nat20 restores counters +- Undo damage at 0 HP restores fail count +- Undo stabilize restores dying state + +## Phase 2: Combat Test Integration + +### Update `shared/tests/turn.combat.test.js` + +Add phases: +- Nat20 phase: roll nat20, verify 1 HP + conscious +- Nat1 phase: roll nat1, verify 2 failures +- Damage-at-zero phase: normal damage + crit damage at 0 HP +- Massive damage phase: instant death bypass +- Stabilize phase: stabilize dying character +- Healing-reset phase: heal at 0 HP, verify counters reset +- Assert death save state at each phase + +### Update `src/tests/Combat.scenario.test.js` + +Add action functions: +- deathSaveActionNat20(name) +- deathSaveActionNat1(name) +- stabilizeAction(name) +- applyDamageAtZero(name, amount, isCrit) +- applyMassiveDamage(name) +- healingResetAction(name, amount) + +Add scenario phases: +- Round 10-20: nat20/nat1 scenarios +- Round 30-40: damage-at-zero scenarios (normal + crit) +- Round 50-60: stabilize scenarios +- Round 70-80: massive damage scenarios +- Round 90-100: healing-reset scenarios +- Verify log entries include all death save types + +### Update `scripts/combat.js` + +Verify death save events: +- Check death_save events in log +- Verify nat20, nat1, damage_at_zero, massive_damage log types +- Verify stabilize events +- Verify healing_reset events + +## Phase 3: Extend `deathSave` Function + +Add roll result parameter: +```ts +async function deathSave(encounter, participantId, type, n, rollResult, ctx) { + // rollResult: number (1-20) or null for manual input +} +``` + +Handle nat20: +```ts +if (rollResult === 20) { + updates.hp = 1; + updates.deathSaves = 0; + updates.deathFails = 0; + updates.isDying = false; + updates.isStable = false; + status = 'conscious'; +} +``` + +Handle nat1: +```ts +if (rollResult === 1) { + const currentFails = participant.deathFails || 0; + updates.deathFails = Math.min(3, currentFails + 2); + // Check for death... +} +``` + +## Phase 4: Extend `applyHpChange` Function + +Add damage-at-zero logic: +```ts +if (changeType === 'damage' && participant.currentHp === 0) { + const failuresToAdd = isCritical ? 2 : 1; + const currentFails = participant.deathFails || 0; + updates.deathFails = Math.min(3, currentFails + failuresToAdd); + updates.isStable = false; + // Check for death... +} +``` + +Add massive damage rule: +```ts +if (changeType === 'damage' && damage >= participant.currentHp + participant.maxHp) { + updates.hp = 0; + updates.isDying = true; + updates.deathSaves = 0; + updates.deathFails = 3; + updates.isStable = false; +} +``` + +Add healing reset logic: +```ts +if (changeType === 'heal' && (participant.currentHp === 0 || participant.isStable || participant.isDying)) { + updates.deathSaves = 0; + updates.deathFails = 0; + updates.isDying = false; + updates.isStable = false; +} +``` + +## Phase 5: New `stabilizeParticipant` Function + +```ts +async function stabilizeParticipant(encounter, participantId, ctx) { + // hp=0, isStable=true, isDying=false, counters=0 + // Log: "Stabilized" +} +``` + +## Phase 6: UI Extensions + +### Phase 6a: Roll Result Modal + +Add roll result input: +- Modal when clicking death save button +- Input: d20 result (1-20) or "manual" +- Auto-detect nat20/nat1 + +### Phase 6b: Nat1/Nat20/Stabilize Buttons + +Death save row layout: +``` +[Saves: □ □ □] [Fails: □ □ □] +[Nat1] [Nat20] [Stabilize] +``` + +**Nat1 button:** +- Red icon (AlertTriangle) +- Click → deathSave(participantId, 'fail', null, 1, ctx) +- Adds 2 failures immediately +- Log: "Nat 1! [name] takes 2 death save failures" +- Disable if deathFails >= 2 (would cause death anyway) + +**Nat20 button:** +- Gold icon (Zap) +- Click → deathSave(participantId, 'success', null, 20, ctx) +- Sets hp=1, resets counters, conscious +- Log: "Nat 20! [name] restored to 1 HP, conscious" + +**Stabilize button:** +- Emerald icon (HeartPulse) +- Only show when dying (hp=0, !isStable, !dead) +- Click → stabilizeParticipant(participantId, ctx) +- Sets isStable=true, counters=0 +- Log: "[name] stabilized at 0 HP" +- Hide if already isStable + +**Conditional visibility:** +- All buttons: only when hp === 0 && !dead +- Stabilize: hide if already isStable +- Nat1: disable if deathFails >= 2 + +### Phase 6c: Crit Checkbox for Damage + +Add crit checkbox: +- For damage at 0 HP +- Pass crit to applyHpChange +- UI: checkbox "Critical hit?" + +### Phase 6d: Enhanced Log Messages + +- Nat20: "Nat 20! [name] restored to 1 HP, conscious" +- Nat1: "Nat 1! [name] takes 2 death save failures" +- Damage at 0: "[name] takes [n] damage while dying" +- Massive damage: "Massive damage! [name] instantly killed" +- Stabilize: "[name] stabilized at 0 HP" +- Healing reset: "[name] healed to [n] HP, death saves cleared" + +## Phase 7: Integration Tests + +File: `shared/tests/turn.deathsave.flow.test.js` + +Test full scenarios: +- Dying → nat20 → conscious → take damage → dying +- Dying → nat1 → 2 fails → another fail → dead +- Dying → stabilize → damage → dying again +- Massive damage bypasses all +- Healing resets mid-death-save loop + +## Execution Order + +1. Phase 1: Write all test files (fail expected) +2. Phase 2: Update combat tests (fail expected) +3. Phase 3: Extend deathSave, run tests +4. Phase 4: Extend applyHpChange, run tests +5. Phase 5: Add stabilizeParticipant, run tests +6. Phase 6a: Roll result modal +7. Phase 6b: Nat1/Nat20/Stabilize buttons +8. Phase 6c: Crit checkbox +9. Phase 6d: Enhanced log messages +10. Phase 7: Integration tests +11. Manual testing: full scenarios in dev server + +## Risk Notes + +- Roll result input: optional field (DM may not want to input every roll) +- Crit detection: manual checkbox (no way to auto-detect) +- Massive damage: optional rule (some tables ignore) +- Backward compat: existing API must still work with null rollResult +- Nat1/Nat20/Stabilize buttons: DM-optional, not required to use \ No newline at end of file diff --git a/TODO.md b/TODO.md index f4893fc..9d1cc5e 100644 --- a/TODO.md +++ b/TODO.md @@ -4,6 +4,37 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. ## Open +### FEAT: critical damage button for dying/stable participants +- Shared logic already supports `applyHpChange(..., { isCriticalHit:true })`. +- UI only has normal Damage, which adds 1 failure at 0 HP. +- Add button near Revive/Stabilize: **Crit Damage** / **Critical Hit**. +- Use only for dying/stable character/NPC at 0 HP; adds 2 death-save failures. +- Dead target remains dead; no action. + +### dm list - keep active particpant in view (scroll) + + +#### debug feature - button to delet all campaigns - only in dev builds + +### bug - start/end/resume combat should not be in undo I think... + +### combat.js doesnt seem to actually exercise the add characters. there are no characters in the campaign. only in the encounter.. + + +## dying>stabe state - add +1hp in 1d4 hours DISPLAY (maybe show a rolled hours too) for while stable + +### FEAT: player display fade transitions for inactive state +- Inactive is DM-triggered via Mark Inactive. +- Player display should fade inactive participant out, then remove from display list. +- Reactivating should fade participant in. +- DM display keeps inactive participant visible. +- Dead state does not imply inactive/disabled. +- Dying/stable must not fade out or leave layout holes. +- Dead can keep skull/Dead label; create some good visual cues, no removal. +- + + + ### TEST GAP: current branch changes need focused coverage - Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`. - Server SQL query test: `where + orderBy + limit` should return latest logs for one encounter only. @@ -45,7 +76,12 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. - Decide UX before fix. -### death saves feature - dead PCs should not get removed from initiative! DAMMIT! +### FEAT: generic/non-5e rules mode +- Campaign/encounter ruleset toggle: `5e` vs `generic`. +- Generic mode turns off death saves/state machine. +- Generic mode allows negative HP. +- 5e mode rejects negative damage/heal and clamps HP at 0. + ### FEAT: clarify "Is NPC" in add-participant @@ -120,12 +156,13 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. case-insensitive. maxLength 40. Combat + replay tests prove arbitrary string ids survive round-trip. -### Death saves: D&D 5e model (DONE) -- Separate success/fail tracking. deathSave(enc, id, type, n), - type='success'|'fail'. Fields: deathSaves, deathFails, isStable, isDying. - 3 success=stable, 3 fail=dead. Returns {patch, log, status, isDying}. - Old single-counter broken (successes missing). Old data lost (feature - never worked in prod). UI: green ✓ + red ✕ rows, Stabilized/Dying badges. +### Death saves: D&D 5e status model (DONE) +- `status` is source of truth: conscious, dying, stable, dead. +- Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP. +- Death-save actions: Success, Fail, Nat1, Nat20, Stabilize. +- Revive: dead → 0 HP, stable/unconscious, active. +- Dead characters/NPCs stay in encounter/initiative until DM removes or marks inactive. +- Player display hides inactive participants; DM display keeps them visible. ### FEAT-3: initiative first-class entry (DONE) - Initiative field at add-char, add-monster, edit participant. @@ -199,9 +236,9 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. ### BUG-18: stale comments reference deleted memory adapter - FIXED. -### FEAT-1: Dead participants stay in turn order -- DONE --- applyHpChange no longer flips isActive on death. Dead stay in - rotation, nextTurn visits them, PCs get death-save turn. +### FEAT-1: Dead characters/NPCs stay in turn order +- DONE --- character/NPC death does not auto-remove. DM controls inactive/remove. +- Non-NPC monsters still become inactive automatically at death. ### combat.scenario 100 rounds not turns - DONE --- loops by actual round-wrap count. diff --git a/docs/DEATH_SAVES.md b/docs/DEATH_SAVES.md new file mode 100644 index 0000000..8fa7030 --- /dev/null +++ b/docs/DEATH_SAVES.md @@ -0,0 +1,142 @@ +# Death Saves (D&D 5e) + +Concept and rule structure for the death-save state machine. Reference for how +combat tracking should behave at 0 HP. This is the 5e death-save loop, not a +code spec. + +## Scope: tracker tracks, DM decides + +This tracker is not a virtual tabletop. It does not roll dice or interpret the +table. The DM rolls at the table, decides what happened, and feeds the result +into the tracker through the UI. The tracker records the result and applies the +automatic pieces. + +- DM does: rolls the d20, determines crits vs normal hits, declares damage + amounts, decides when to stabilize or heal. +- Tracker does: increments success/failure counters on DM input, resolves the + terminal states (3 successes → stable, 3 failures → dead), resets counters on + heal or stabilize, records HP changes. + +The transitions below describe the rules the tracker must honor once the DM +hands it a result. The DM is the source of truth for what happened; the tracker +is the source of truth for what state that produces. + +## State + +A character or NPC at 0 HP enters a death-save loop. Track per death-save participant: + +```ts +deathSaveSuccesses: 0 | 1 | 2 | 3 +deathSaveFailures: 0 | 1 | 2 | 3 +status: "conscious" | "dying" | "stable" | "dead" +hp: number +``` + +## Transitions + +**Drop to 0 HP** (damage, character/NPC): +- If damage >= currentHp + maxHp → outright dead (massive damage). Equivalently: after reducing currentHp to 0, overflow damage >= maxHp. +- Otherwise → `status = "dying"`, counters reset to 0. + +**Drop to 0 HP** (monster): +- Non-NPC monsters do not use death saves. +- `status = "dead"`, counters reset, `isActive = false`. + +**Death save** (d20 roll on a dying character/NPC turn): + +```ts +if roll === 1: failures += 2 // natural 1 = two failures +else if roll === 20: hp = 1; successes = 0; failures = 0; status = "conscious" // nat 20 = 1 HP, conscious +else if roll >= 10: successes += 1 +else: failures += 1 +``` + +Then resolve immediately on the click/action that reaches 3: + +```ts +if failures >= 3: status = "dead"; successes = 0; failures = 0 +if successes >= 3: status = "stable"; successes = 0; failures = 0 +``` + +The UI must transition immediately. A third failure shows Dead state, not three +active failure boxes. A third success shows Stable state, not three active +success boxes. + +**Damage while dying** (at 0 HP): + +```ts +failures += isCriticalHit ? 2 : 1 +if failures >= 3: status = "dead" +``` + +Attacks from within 5 ft against an unconscious creature are auto-crits, so +melee vs a dying character/NPC adds 2 failures. + +**Healing while dying or stable**: + +Any +HP received while `dying` or `stable` makes the character/NPC conscious. +This usually comes from magic, or from the stable-state time-delayed recovery +(1 HP after 1d4 hours, if the DM chooses to track it here). + +```ts +hp = max(1, hp + amount) +successes = 0 +failures = 0 +status = "conscious" +``` + +Any HP regained from `dying` or `stable` clears all counters and ends the +death-save loop. This does **not** apply to `dead`; dead requires revive flow, +revival magic, or DM state override, not normal healing. + +**Stabilize** (manual action, not a save): + +```ts +hp = 0 +successes = 0 +failures = 0 +status = "stable" +``` + +## Stable state + +- Stays at 0 HP, unconscious, no longer makes death saves. +- 3 successes = **stable (unconscious)**, NOT conscious. NOT 1 HP. + 1 HP only comes from a natural 20 or healing. +- Taking any damage ends stable → back to `dying` (counters reset, then damage + applies its failure per "damage while dying"). + +## Tool-specific initiative behavior + +Death is a combat state, not an automatic removal action. + +When a character or NPC becomes `dead`: +- Do **not** remove them from initiative. +- Do **not** automatically deactivate them. +- Do **not** remove them from the encounter participants list. +- Leave them in turn order. Other table/tool events may still matter on that + initiative count. +- DM may manually mark them inactive or remove them. + +When a non-NPC monster becomes `dead`: +- Set `isActive = false` automatically. +- Keep it in the encounter participants list until DM removes it. +- Player display hides it because inactive participants are hidden. + +## Invariants + +- `status === "dying"` ⟺ hp === 0 AND no terminal flag. +- `status === "stable"` ⟹ hp === 0, unconscious, counters 0. +- `status === "dead"` ⟹ hp === 0, no further saves, counters 0. +- `status === "dead"` ⟹ participant remains in initiative and participants until DM removes them. +- `status === "dead"` does not itself mean inactive; monster death sets inactive as a separate rule. +- Normal +HP applies to `dying` or `stable`, not `dead`; dead requires revive, revival, or DM override. +- Successes and failures tracked **independently**. 2 fail + 3 success = stable. + Counters do not need to be consecutive. +- A roll of 10+ is a success. 9 or lower is a failure. + +## Sources + +- [PHB 5e: Death Saving Throws](https://roll20.net/compendium/dnd5e/Rules%3ADamage%20and%20Healing) +- [Reddit: What happens on 3 successful death saves?](https://www.reddit.com/r/DnD/comments/io5js7/what_happens_if_you_succeed_in_3_death_saving/) +- [Roll20: Conditions (Unconscious)](https://roll20.net/compendium/dnd5e/Conditions) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 675c5c6..b26deef 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -174,7 +174,7 @@ Runs pure turn.js combat, audits 9 invariant classes per round: 1. rotation integrity (skip/dupe) 2. HP bounds (0 ≤ hp ≤ max, no NaN) -3. isActive consistency (dead = inactive) +3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled) 4. turnOrder no dup ids 5. turnOrder ids all active 6. currentTurn valid + active diff --git a/docs/ENCOUNTER_BUILDER.md b/docs/ENCOUNTER_BUILDER.md index 6eb5014..701e228 100644 --- a/docs/ENCOUNTER_BUILDER.md +++ b/docs/ENCOUNTER_BUILDER.md @@ -53,16 +53,16 @@ Object in `encounter.participants[]`: |---|---|---| | `id` | string | `generateId()` | | `name` | string | | -| `type` | `'character'` \| `'monster'` | character = PC (death saves), monster = hostile/NPC | +| `type` | `'character'` \| `'npc'` \| `'monster'` | character/NPC = death saves, monster = dead at 0 HP | | `originalCharacterId` | string\|null | links back to campaign character if type=character | | `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. | | `maxHp` | int | | -| `currentHp` | int | 0 = dead/dying | -| `isNpc` | bool | monster flagged NPC (display color, no death saves) | +| `currentHp` | int | 0 = dead/dying/stable by `status` | | `conditions` | array | condition ids from `CONDITIONS` list | | `isActive` | bool | in turn rotation? false = skipped by nextTurn | -| `deathSaves` | int | PC only, 0-3 fails | -| `isDying` | bool | death animation flag (player display) | +| `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth | +| `deathSaveSuccesses` | int | character/NPC only, 0-3 successes | +| `deathSaveFailures` | int | character/NPC only, 0-3 failures | ## Build flow (UI) @@ -99,7 +99,7 @@ ParticipantManager section. Two paths: - **Monster Name** (`placeholder: "e.g., Dire Wolf"`) - **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2) - **Max HP** (`DEFAULT_MAX_HP` = 10) -- **Is NPC?** checkbox (flag, changes display color) +- **Is NPC?** checkbox (sets `type: 'npc'`, keeps monster/DM-control color, enables death saves) - Click **Add to Encounter** - Initiative auto-rolled: `rollD20() + mod` @@ -113,13 +113,13 @@ ParticipantManager section. Two paths: Participant object added: ```js { id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp, - isNpc, conditions:[], isActive:true, deathSaves:0, isDying:false } + conditions:[], isActive:true, status:'conscious', deathSaveSuccesses:0, deathSaveFailures:0 } ``` ### 6. Reorder before start (tie-break) Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`. -Post-start drag: see BUG-13/14 in `TODO.md` (cross-init + pointer semantics untested). +During active combat, cross-current-pointer drag is blocked to avoid skip/double-act ambiguity. Paused combat can reorder same-initiative ties freely. ## Combat flow (UI) @@ -135,8 +135,9 @@ InitiativeControls panel (sticky, right side). ### Next Turn - **Next Turn** button (disabled if paused) - Advances to next active participant in `turnOrderIds` -- Wraps at end → `round += 1`, re-sorts active by initiative at round start -- Dead (`isActive:false`) skipped, stay in rotation +- Wraps at end → `round += 1` +- No re-sort after start; 1-list order remains source of truth +- Inactive (`isActive:false`) participants are skipped, but stay in slot ### Pause / Resume - **Pause Combat** → `isPaused=true`, Next Turn disabled @@ -147,11 +148,19 @@ InitiativeControls panel (sticky, right side). Per-participant input + buttons: - Number input - **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)` -- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` -- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking +- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` for living/dying/stable participants; normal heal does not affect dead participants +- Character/NPC at 0 HP → `status:'dying'`, death-save controls, unconscious condition +- Monster at 0 HP → `status:'dead'`, `isActive:false` +- Massive damage (`damage >= currentHp + maxHp`) → dead -### Death saves (PC only, at 0 HP) -3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal. +### Death saves (character/NPC only, at 0 HP) +Action buttons: Success, Fail, Nat1, Nat20, Stabilize. +- Success: +1 success; 3 successes → stable/unconscious at 0 HP +- Fail: +1 failure; 3 failures → dead +- Nat1: +2 failures +- Nat20: 1 HP, conscious, counters reset +- Stabilize: stable/unconscious at 0 HP, counters reset +- Revive: dead → 0 HP, stable/unconscious, active ### Conditions - Click participant → expand conditions picker (all 22 from `CONDITIONS`) @@ -170,7 +179,7 @@ What it shows: - Round + current turn participant - All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix) - HP bars, conditions, death saves -- Inactive monsters hidden (pre-staged reserves) +- Inactive participants hidden (including auto-inactive dead monsters and DM-hidden reserves) Driven by `activeDisplay/status` doc. Controlled by **Open Player Window** button (sets active campaign+encounter) or Start Combat (auto-sets). @@ -181,11 +190,11 @@ Key architecture. `turnOrderIds === participants.map(p => p.id)` always. Single - **Display** = `participants[]` order (AdminView + DisplayView, no re-sort) - **Turn rotation** = `turnOrderIds` (mirrors participants[]) - **Drag** = source of truth, overrides initiative -- **Add mid-combat** = append to participants[] + sync (BUG-14: init-insert broken post-drag) +- **Add mid-combat** = slot by initiative into participants[] + sync - **Toggle active** = flip `isActive` only, stay in slot - **Remove** = drop from participants[] + sync, advance current if needed -No re-sort after `startEncounter` except round-wrap (re-sorts active by init at top of round). +No re-sort after `startEncounter`. ## Storage paths quick reference @@ -202,7 +211,8 @@ logs/{logId} action log entry - Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override. - Pause before big roster changes (adds/removes). Resume re-syncs cleanly. - Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added). -- Dead monsters stay in rotation, skipped. Remove via trash icon to clean list. +- Dead monsters become inactive automatically and are skipped. Remove via trash icon to clean list. +- Dead characters/NPCs stay active until DM marks inactive, removes, or revives them. - Player display auto-follows Start Combat. Manual control via Open Player Window. See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs. diff --git a/docs/LOG_REDESIGN_PLAN.md b/docs/LOG_REDESIGN_PLAN.md new file mode 100644 index 0000000..f6a0a17 --- /dev/null +++ b/docs/LOG_REDESIGN_PLAN.md @@ -0,0 +1,133 @@ +# Log System Redesign Plan + +**Status:** DRAFT — not started +**Branch:** `feat-turn-writes-logs` +**Goal:** Lean mutation deltas. Kill bloated payload/undo duplication. + +## Problem + +Current events = 10-16KB each. `add_participant` row = 16KB to say "Fighter added, init 22." Root cause: full participant roster stored 3x (payload + undo_payload.updates + undo_payload.redo). + +DB: 2486 docs, most bloated. Replay trace 1.6-5.5MB for ~250 steps. + +Old log (pre-refactor) = message string + small undo object. Proven worked for undo/redo + viewer. Just wanted MORE STRUCTURE, not MORE DATA. Overbuilt instead. + +## Intention + +Each event = mutation delta + lean snapshot. Structured for filter/group/analyzer. No roster arrays. + +### Canonical lean event shape + +```js +{ + id, ts, type, // identity + filter key + message, // human text (viewer displays) + encounterId, encounterName, // which encounter + participantId, participantName, // who (nullable: pause/nextTurn have none) + // type-specific scalar delta (one of these clusters per type): + // add: { init, maxHp, type: 'character'|'monster' } + // remove: { dead: bool } + // damage/heal:{ amount } + // condition: { condition } + // toggleActive:{ revive: bool } + // reorder: { draggedId, targetId } + // update: { changedFields: ['notes', ...] } + // nextTurn: (none — snapshot carries new round/pointer) + // pause/resume/start/end: (none) + snapshot: { // lean, ALREADY GOOD — keep as-is + round, currentTurnParticipantId, turnOrderIds, activeIds + }, + undo: // minimal +} +``` + +### Undo inverses (id-based, minimal) + +| type | undo | +|------|------| +| add_participant | `{ participantId }` → removeParticipant by id | +| remove_participant | full participant obj (must restore — only place full obj needed) | +| damage | `{ amount: -amount }` or heal back | +| heal | `{ amount: -amount }` or damage back | +| condition | `{ condition }` (toggle is its own inverse) | +| toggleActive | `{ revive: !revive }` | +| reorder | `{ draggedId, targetId }` (swap back) | +| update | `{ changedFields: [...], oldValues: {...} }` | +| nextTurn | `{ currentTurnParticipantId, round }` from snapshot delta | +| pause/resume | toggle | +| start/end | full prior scalar state | + +**No `redo` field.** Redo = re-apply forward delta from event itself. + +**No `undo_payload` wrapper.** Just `undo` object. + +## 4 Consumers + +1. **Viewer** — shows `message` + `type` + `participantName` + `undone` flag. Legacy logs (no type) display message only. +2. **Undo/redo** — apply `undo` inverse by id. Id-based, minimal. +3. **Replay** — turn.js funcs write own lean events. Trust func return. NO read-back. JSONL trace = lean snapshots + types. +4. **Analyzer** — reads `snapshot` sequence + `type`. Reconstructs roster from event stream (add/remove by id). NO payload arrays needed. + +## Legacy Support + +- **Viewer:** yes — show old message-only logs, no type parse +- **Undo:** no — old logs unundoable (acceptable, sit inert) +- **Analyzer:** best-effort — snapshot may be absent on legacy → skip invariant check for that run + +## Why Lean + +- `add_participant` 16KB → ~150 bytes +- Replay trace 5.5MB → ~250KB +- Undo still works (proven old log did same) +- Analyzer unaffected (uses snapshot+type, not payload) + +## Migration Plan + +### Phase 1: Redesign event writers (shared/turn.js) + +- [ ] Rewrite `buildEntry()` to produce lean shape (delta fields per type) +- [ ] Rewrite `commit()` — no patch storage, write lean event +- [ ] Per-func: compute minimal delta + inverse from enc before/after +- [ ] Drop `payload`, `undo_payload`, `undo_payload.redo` + +### Phase 2: Update undo executor + +- [ ] Find undo consumer (App.js `applyUndo` or similar) +- [ ] Switch to id-based inverse application +- [ ] Handle each type's undo shape +- [ ] Drop `transactionalUndo` full-patch path if exists + +### Phase 3: Update replay trace + +- [ ] `replay-combat.js` callStep: trust return, snapshot only (no read-back) +- [ ] Trace = `{step, ts, type, pre:leanSnap, post:leanSnap}` — no participant arrays + +### Phase 4: Update analyzer + +- [ ] `analyze-turns.js` already uses snapshot+type only — verify no payload dep +- [ ] Reconstruct roster from event stream for skip/double-act checks (id-level) + +### Phase 5: Legacy compat + +- [ ] `logEvent.js normalizeEvent` — lift legacy into lean shape (type='legacy', message only) +- [ ] Viewer handles legacy gracefully +- [ ] Download = lean events only (legacy excluded or message-only) + +### Phase 6: Cleanup + +- [ ] Delete old bloated docs from DB? Or leave (inert, viewer reads message)? +- [ ] Verify build CI=true passes +- [ ] Verify analyzer clean on fresh replay +- [ ] Verify undo works on new events + +## NOT in scope + +- Fixing 5 pre-existing initiative bugs (logged in TODO.md, separate) +- Changing snapshot shape (already lean) +- Changing viewer UI (just data source) + +## Open Questions + +- Undo for `remove_participant`: must restore full obj (only place full obj needed). Acceptable? +- Should legacy logs be deleted from DB or left inert? +- Download format: lean JSON array (no legacy) — confirm diff --git a/docs/TESTING.md b/docs/TESTING.md index 65a76da..df5ca2d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -118,7 +118,7 @@ node tests/audit/audit-rotation.js Runs pure turn.js combat. Audits 9 invariant classes per round: 1. rotation integrity (skip/dupe) 2. HP bounds (0 ≤ hp ≤ max, no NaN) -3. isActive consistency (dead = inactive) +3. isActive consistency (inactive skipped; monster death auto-inactive; character/NPC death DM-controlled) 4. turnOrder no dup ids 5. turnOrder ids all active 6. currentTurn valid + active diff --git a/scripts/combat/replay.js b/scripts/combat/replay.js index 4b3febe..41a6a85 100644 --- a/scripts/combat/replay.js +++ b/scripts/combat/replay.js @@ -301,11 +301,11 @@ module.exports = async function replay(args) { (e) => updateParticipant(e, actor.id, { notes: `edited r${enc.round}` }, ctx))).enc; } - if (actor.currentHp <= 0 && !actor.isNpc) { + if (actor.status === 'dying' && !actor.isNpc) { if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`); enc = (await callStep('deathSave', - { participant: actor.name, type: 'success', n: 1 }, - (e) => deathSave(e, actor.id, 'success', 1, ctx))).enc; + { participant: actor.name, outcome: 'success' }, + (e) => deathSave(e, actor.id, 'success', ctx))).enc; } if (totalTurns % 9 === 0) { diff --git a/shared/tests/_helpers.js b/shared/tests/_helpers.js index 2b0de77..ddf37b3 100644 --- a/shared/tests/_helpers.js +++ b/shared/tests/_helpers.js @@ -35,6 +35,7 @@ function createMockStorage() { // filters updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); }, logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); }, + getLogs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); }, }; } @@ -63,8 +64,8 @@ function readyEnc(opts = {}) { currentTurnParticipantId: null, turnOrderIds: [], participants: opts.participants || [ - { id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], deathSaves: 0, deathFails: 0, isStable: false, isDying: false }, - { id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], deathSaves: 0, deathFails: 0, isStable: false, isDying: false }, + { id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }, + { id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }, ], }; } diff --git a/shared/tests/turn.characterization.test.js b/shared/tests/turn.characterization.test.js index 3031b08..cf5d32e 100644 --- a/shared/tests/turn.characterization.test.js +++ b/shared/tests/turn.characterization.test.js @@ -245,27 +245,26 @@ describe('applyHpChange', () => { expect(newEnc.participants[0].currentHp).toBe(10); }); - test('damage to 0 deactivates + keeps turn order (unified)', async () => { - // Unified: death flips isActive=false (removed from active rotation). - // turnOrderIds unchanged (no turn-order patch on death). + test('damage to 0 marks dying, keeps active, keeps turn order', async () => { const { storage, ctx } = mockCtx(); - const ps = [p('a', 10, { currentHp: 3 }), p('b', 5)]; + const ps = [p('a', 10, { type: 'character', currentHp: 3 }), p('b', 5)]; const e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' }); const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); expect(newEnc.participants[0].currentHp).toBe(0); - expect(newEnc.participants[0].isActive).toBe(false); + expect(newEnc.participants[0].status).toBe('dying'); + expect(newEnc.participants[0].isActive).not.toBe(false); expect(lastPatch(storage).turnOrderIds).toBeUndefined(); expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined(); }); - test('heal above 0 reactivates + resets death saves (unified)', async () => { - // Unified: revive from 0 flips isActive=true, deathSaves reset. + test('heal from dying makes conscious and resets death-save counters', async () => { const { ctx } = mockCtx(); - const ps = [p('a', 10, { currentHp: 0, isActive: false, deathSaves: 2 })]; + const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveSuccesses: 2, deathSaveFailures: 1 })]; const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx); expect(newEnc.participants[0].currentHp).toBe(5); - expect(newEnc.participants[0].isActive).toBe(true); - expect(newEnc.participants[0].deathSaves).toBe(0); + expect(newEnc.participants[0].status).toBe('conscious'); + expect(newEnc.participants[0].deathSaveSuccesses).toBe(0); + expect(newEnc.participants[0].deathSaveFailures).toBe(0); }); test('heal clamps to maxHp', async () => { @@ -285,27 +284,21 @@ describe('applyHpChange', () => { }); describe('deathSave', () => { - test('increments fails', async () => { + test('fail increments failure counter while dying', async () => { const { ctx } = mockCtx(); - const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })]; - const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 1, ctx); - expect(newEnc.participants[0].deathFails).toBe(1); + const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 0, deathSaveSuccesses: 0 })]; + const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx); + expect(status).toBe('dying'); + expect(newEnc.participants[0].deathSaveFailures).toBe(1); }); - test('clicking same fail decrements (toggle)', async () => { + test('third fail makes dead and resets counters', async () => { const { ctx } = mockCtx(); - const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })]; - const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 2, ctx); - expect(newEnc.participants[0].deathFails).toBe(1); - }); - - test('third fail sets isDying', async () => { - const { ctx } = mockCtx(); - const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })]; - const { enc: newEnc, isDying } = await deathSave(enc(ps), 'a', 'fail', 3, ctx); - expect(newEnc.participants[0].deathFails).toBe(3); - expect(newEnc.participants[0].isDying).toBe(true); - expect(isDying).toBe(true); + const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 2, deathSaveSuccesses: 0 })]; + const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx); + expect(status).toBe('dead'); + expect(newEnc.participants[0].status).toBe('dead'); + expect(newEnc.participants[0].deathSaveFailures).toBe(0); }); }); @@ -326,12 +319,12 @@ describe('toggleCondition', () => { }); describe('reorderParticipants', () => { - test('drag before target (same-init tie)', async () => { + test('drag downward onto target moves after target (same-init tie)', async () => { const { ctx } = mockCtx(); const ps = [p('a', 10), p('b', 10), p('c', 10)]; const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx); - // drag a before c: remove a → [b,c], insert before c → [b,a,c] - expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'a', 'c']); + // drag a downward onto c: remove a → [b,c], insert after c → [b,c,a] + expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'c', 'a']); }); test('cross-init drag blocked (no-op)', async () => { diff --git a/shared/tests/turn.combat.test.js b/shared/tests/turn.combat.test.js index e89374c..d640930 100644 --- a/shared/tests/turn.combat.test.js +++ b/shared/tests/turn.combat.test.js @@ -56,7 +56,7 @@ function setupEncounter() { buildMonsterParticipant({ name:'Goblin2', maxHp:100, initMod:2 }).participant, buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant, buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).participant, - buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, isNpc:true }).participant, + buildMonsterParticipant({ name:'Merchant', maxHp:150, initMod:0, asNpc:true }).participant, ]; // give deterministic ids to monsters for assertions const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' }; @@ -163,12 +163,12 @@ describe('combat integrity (100 rounds, full op coverage)', () => { } } // 5. deathSave - if (actor && actor.currentHp <= 0 && !actor.isNpc) { - try { const r = await deathSave(e, actor.id, 'fail', 1, ctx); e = r.enc; } catch (err) {} + if (actor && actor.status === 'dying' && actor.type !== 'npc') { + try { const r = await deathSave(e, actor.id, 'fail', ctx); e = r.enc; } catch (err) {} } - // 6. removeParticipant + // 6. removeParticipant (monsters/NPC only; dead PCs stay until DM removes manually) if (totalTurns % 5 === 0) { - const dead = e.participants.find(part => (part.isDying || part.currentHp <= 0) && (part.isNpc || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf')); + const dead = e.participants.find(part => (part.status === 'dead' || part.currentHp <= 0) && (part.type === 'npc' || part.name.startsWith('Goblin') || part.name === 'OrcBoss' || part.name === 'Wolf')); if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} } } // 7. addParticipant diff --git a/shared/tests/turn.dead-skip.test.js b/shared/tests/turn.dead-skip.test.js index 37d9196..368270f 100644 --- a/shared/tests/turn.dead-skip.test.js +++ b/shared/tests/turn.dead-skip.test.js @@ -1,28 +1,17 @@ -// Unified behavior (App main): death flips isActive=false, dead participant -// removed from active rotation, skipped by nextTurn. deathSave is a manual -// DM action (button), not tied to rotation. turnOrderIds unchanged on death -// (only isActive flag flips). Revive (heal from 0) reactivates. -// -// Previous "M4: dead stays in rotation" concept reversed by unification. -// -// New API: mutating funcs are async, take ctx last, write encounter + log -// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`. +'use strict'; const shared = require('@ttrpg/shared'); const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared; const { mockCtx } = require('./_helpers'); -function p(id, init, extra = {}) { - return makeParticipant({ - id, name: id, type: 'monster', - initiative: init, maxHp: 100, currentHp: 100, - ...extra, - }); -} function pc(id, init, extra = {}) { return makeParticipant({ - id, name: id, type: 'character', - initiative: init, maxHp: 100, currentHp: 100, + id, + name: id, + type: 'character', + initiative: init, + maxHp: 100, + currentHp: 100, ...extra, }); } @@ -30,69 +19,67 @@ function enc(ps) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[] }; } +const byId = (e, id) => e.participants.find(p => p.id === id); -describe('unified: death deactivates, dead skipped in rotation', () => { - test('dead PC not removed from turnOrderIds (stays in list, inactive)', async () => { +describe('death state: participant remains in initiative and encounter', () => { + test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => { const { ctx } = mockCtx(); - const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; - let e = enc(ps); + let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]); e = await startEncounter(e, ctx); const orderBefore = e.turnOrderIds.slice(); - // kill b + e = await applyHpChange(e, 'b', 'damage', 100, ctx); + + expect(byId(e, 'b').status).toBe('dying'); + expect(byId(e, 'b').isActive).not.toBe(false); + expect(e.participants.map(p => p.id)).toContain('b'); expect(e.turnOrderIds).toEqual(orderBefore); }); - test('dead PC skipped by nextTurn (isActive=false)', async () => { + test('dead PC remains in participants and turnOrderIds after three failures', async () => { const { ctx } = mockCtx(); - const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; - let e = enc(ps); + let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]); e = await startEncounter(e, ctx); - // kill b + const orderBefore = e.turnOrderIds.slice(); e = await applyHpChange(e, 'b', 'damage', 100, ctx); - // advance: a→c (b skipped, inactive) + + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + + expect(byId(e, 'b').status).toBe('dead'); + expect(byId(e, 'b').isActive).not.toBe(false); + expect(e.participants.map(p => p.id)).toContain('b'); + expect(e.turnOrderIds).toEqual(orderBefore); + }); + + test('nextTurn may still land on dead PC because DM/tool events may matter', async () => { + const { ctx } = mockCtx(); + let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]); + e = await startEncounter(e, ctx); + e = await applyHpChange(e, 'b', 'damage', 100, ctx); + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = await nextTurn(e, ctx); - expect(e.currentTurnParticipantId).toBe('c'); + + expect(e.currentTurnParticipantId).toBe('b'); + expect(byId(e, 'b').status).toBe('dead'); }); - test('dead PC deathSave fires on manual call (not via rotation)', async () => { + test('deathSave rejected/no-op once dead', async () => { const { ctx } = mockCtx(); - const ps = [pc('a', 20), pc('b', 15)]; - let e = enc(ps); + let e = enc([pc('a', 20), pc('b', 15)]); e = await startEncounter(e, ctx); - // kill b (current = a) e = await applyHpChange(e, 'b', 'damage', 100, ctx); - // b is dead: DM can still fire deathSave (manual action) - const { enc: newEnc } = await deathSave(e, 'b', 'fail', 1, ctx); - const b = newEnc.participants.find(x => x.id === 'b'); - expect(b.deathFails).toBe(1); - }); + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + e = (await deathSave(e, 'b', 'fail', ctx)).enc; + const before = byId(e, 'b'); - // D1 unification: shared now matches App main — death flips isActive=false. - // Dead participant removed from active rotation (skipped by nextTurn). - test('dead PC auto-set isActive=false by applyHpChange', async () => { - const { ctx } = mockCtx(); - const ps = [pc('a', 20), pc('b', 15)]; - let e = enc(ps); - e = await startEncounter(e, ctx); - e = await applyHpChange(e, 'b', 'damage', 100, ctx); - const b = e.participants.find(x => x.id === 'b'); - expect(b.isActive).toBe(false); - }); + try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {} - test('revive (heal from 0) reactivates participant', async () => { - const { ctx } = mockCtx(); - const ps = [pc('a', 20), pc('b', 15)]; - let e = enc(ps); - e = await startEncounter(e, ctx); - e = await applyHpChange(e, 'b', 'damage', 100, ctx); - const dead = e.participants.find(x => x.id === 'b'); - expect(dead.isActive).toBe(false); - // heal b back up - e = await applyHpChange(e, 'b', 'heal', 50, ctx); - const revived = e.participants.find(x => x.id === 'b'); - expect(revived.isActive).toBe(true); - expect(revived.currentHp).toBe(50); - expect(revived.deathSaves).toBe(0); + expect(byId(e, 'b')).toMatchObject(before); }); }); diff --git a/shared/tests/turn.deathsave.test.js b/shared/tests/turn.deathsave.test.js index a522301..71f6ee1 100644 --- a/shared/tests/turn.deathsave.test.js +++ b/shared/tests/turn.deathsave.test.js @@ -1,92 +1,334 @@ -// Death saves: broken model. Current = single counter, 3=dying. D&D 5e needs -// separate success/fail tracking: -// 3 successes = stable (0hp unconscious, safe until healed) -// 3 failures = dead -// Current tracks "saves" (really fails via red X UI), no successes at all. -// "Stable" state doesn't exist. -// -// RED was: prove current can't model 3-success stability. -// -// New API: deathSave is async, takes ctx, returns { enc, status, isDying }. -// applyHpChange + startEncounter are async, take ctx, return newEnc. - 'use strict'; -const { makeParticipant, startEncounter, applyHpChange, deathSave } = require('@ttrpg/shared'); +const { + makeParticipant, + startEncounter, + applyHpChange, + deathSave, + stabilizeParticipant, + reviveParticipant, +} = require('@ttrpg/shared'); const { mockCtx } = require('./_helpers'); -function p(id) { - return makeParticipant({ id, name: id, type: 'character', - initiative: 10, maxHp: 100, currentHp: 100 }); +function pc(id, extra = {}) { + return makeParticipant({ + id, + name: id, + type: 'character', + initiative: 10, + maxHp: 100, + currentHp: 100, + ...extra, + }); } -function enc(ps, extra = {}) { - return { name:'t', participants:ps, isStarted:false, isPaused:false, - round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; +function monster(id, extra = {}) { + return makeParticipant({ + id, + name: id, + type: 'monster', + initiative: 10, + maxHp: 100, + currentHp: 100, + ...extra, + }); } -describe('death saves: missing success/stable model', () => { - test('3 successes makes character stable (not dead, not dying)', async () => { - const { ctx } = mockCtx(); - // Character at 0hp. Roll 3 successful death saves. - let e = enc([p('a')]); - e = await startEncounter(e, ctx); - e = await applyHpChange(e, 'a', 'damage', 100, ctx); // 0hp - expect(e.participants[0].currentHp).toBe(0); +function enc(participants, extra = {}) { + return { + id: 'e1', + name: 'death-save-test', + participants, + isStarted: false, + isPaused: false, + round: 0, + currentTurnParticipantId: null, + turnOrderIds: [], + ...extra, + }; +} - // Roll 3 successful saves. Should mark stable. - let res; - res = await deathSave(e, 'a', 'success', 1, ctx); e = res.enc; - res = await deathSave(e, 'a', 'success', 2, ctx); e = res.enc; - res = await deathSave(e, 'a', 'success', 3, ctx); e = res.enc; +async function startedAtZero() { + const { ctx } = mockCtx(); + let e = enc([pc('a')]); + e = await startEncounter(e, ctx); + const originalTurnOrderIds = [...e.turnOrderIds]; + e = await applyHpChange(e, 'a', 'damage', 100, ctx); + return { e, ctx, originalTurnOrderIds }; +} - expect(res.status).toBe('stable'); - expect(e.participants[0].isDying).toBe(false); - expect(e.participants[0].isStable).toBe(true); - expect(e.participants[0].deathFails).toBe(0); +const part = e => e.participants.find(p => p.id === 'a'); + +describe('death saves: D&D 5e status state machine', () => { + test('damage drops character to dying without removal or deactivation', async () => { + const { e, originalTurnOrderIds } = await startedAtZero(); + const p = part(e); + + expect(p.currentHp).toBe(0); + expect(p.status).toBe('dying'); + expect(p.deathSaveSuccesses).toBe(0); + expect(p.deathSaveFailures).toBe(0); + expect(p.isActive).not.toBe(false); + expect(e.participants.map(x => x.id)).toContain('a'); + expect(e.turnOrderIds).toEqual(originalTurnOrderIds); }); - test('3 failures makes character dead (isDying for removal)', async () => { + test('damage drops non-NPC monster to dead/inactive, not dying', async () => { const { ctx } = mockCtx(); - let e = enc([p('a')]); + let e = enc([monster('a')]); e = await startEncounter(e, ctx); + const orderBefore = [...e.turnOrderIds]; + e = await applyHpChange(e, 'a', 'damage', 100, ctx); - let res; - res = await deathSave(e, 'a', 'fail', 1, ctx); e = res.enc; - res = await deathSave(e, 'a', 'fail', 2, ctx); e = res.enc; - res = await deathSave(e, 'a', 'fail', 3, ctx); e = res.enc; + expect(part(e).status).toBe('dead'); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(0); + expect(part(e).conditions).not.toContain('unconscious'); + expect(part(e).isActive).toBe(false); + expect(e.turnOrderIds).toEqual(orderBefore); + }); + test('dead non-NPC monster with stale active flag repairs to inactive', async () => { + const { ctx } = mockCtx(); + let e = enc([monster('a', { currentHp: 0, status: 'dead', isActive: true })]); + e = await startEncounter(e, ctx); + + e = await applyHpChange(e, 'a', 'damage', 1, ctx); + + expect(part(e).status).toBe('dead'); + expect(part(e).isActive).toBe(false); + }); + + test('damage drops NPC to dying', async () => { + const { ctx } = mockCtx(); + let e = enc([monster('a', { type: 'npc' })]); + e = await startEncounter(e, ctx); + + e = await applyHpChange(e, 'a', 'damage', 100, ctx); + + expect(part(e).type).toBe('npc'); + expect(part(e).status).toBe('dying'); + expect(part(e).conditions).toContain('unconscious'); + }); + + test('three failures immediately makes dead, resets counters, keeps participant and turn order', async () => { + let { e, ctx, originalTurnOrderIds } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + expect(part(e).status).toBe('dying'); + expect(part(e).deathSaveFailures).toBe(1); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + expect(part(e).status).toBe('dying'); + expect(part(e).deathSaveFailures).toBe(2); + + const res = await deathSave(e, 'a', 'fail', ctx); + e = res.enc; expect(res.status).toBe('dead'); - expect(e.participants[0].isDying).toBe(true); - expect(e.participants[0].deathFails).toBe(3); + expect(part(e).status).toBe('dead'); + expect(part(e).currentHp).toBe(0); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(0); + expect(part(e).isActive).not.toBe(false); + expect(e.participants.map(x => x.id)).toContain('a'); + expect(e.turnOrderIds).toEqual(originalTurnOrderIds); }); - test('successes and fails tracked independently', async () => { - const { ctx } = mockCtx(); - // Mixed: 1 success, 2 fails, then 2 more successes = stable. - let e = enc([p('a')]); - e = await startEncounter(e, ctx); - e = await applyHpChange(e, 'a', 'damage', 100, ctx); + test('three successes immediately makes stable and resets counters', async () => { + let { e, ctx } = await startedAtZero(); - let res; - res = await deathSave(e, 'a', 'success', 1, ctx); e = res.enc; - expect(e.participants[0].deathSaves).toBe(1); - expect(e.participants[0].deathFails).toBe(0); + e = (await deathSave(e, 'a', 'success', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + const res = await deathSave(e, 'a', 'success', ctx); + e = res.enc; - res = await deathSave(e, 'a', 'fail', 1, ctx); e = res.enc; - res = await deathSave(e, 'a', 'fail', 2, ctx); e = res.enc; - expect(e.participants[0].deathSaves).toBe(1); - expect(e.participants[0].deathFails).toBe(2); - - // 2 more successes → total 3 successes → stable - res = await deathSave(e, 'a', 'success', 2, ctx); e = res.enc; - res = await deathSave(e, 'a', 'success', 3, ctx); expect(res.status).toBe('stable'); - expect(e.participants[0].deathFails).toBe(2); // fails unchanged + expect(part(e).status).toBe('stable'); + expect(part(e).currentHp).toBe(0); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(0); }); - test('deathSave signature: (enc, id, type, n)', async () => { - // type = 'success' | 'fail'. n = 1|2|3. ctx appended by async refactor. - expect(deathSave.length).toBe(5); + test('successes and failures are independent before terminal transition', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + + expect(part(e).status).toBe('dying'); + expect(part(e).deathSaveFailures).toBe(2); + expect(part(e).deathSaveSuccesses).toBe(2); + + e = (await deathSave(e, 'a', 'success', ctx)).enc; + expect(part(e).status).toBe('stable'); + expect(part(e).deathSaveFailures).toBe(0); + expect(part(e).deathSaveSuccesses).toBe(0); + }); + + test('nat1 adds two failures and can kill', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'nat1', ctx)).enc; + expect(part(e).status).toBe('dying'); + expect(part(e).deathSaveFailures).toBe(2); + + e = (await deathSave(e, 'a', 'nat1', ctx)).enc; + expect(part(e).status).toBe('dead'); + expect(part(e).deathSaveFailures).toBe(0); + }); + + test('nat1 at one failure immediately kills', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'nat1', ctx)).enc; + + expect(part(e).status).toBe('dead'); + expect(part(e).deathSaveFailures).toBe(0); + }); + + test('nat20 restores to 1 HP conscious and resets counters', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + e = (await deathSave(e, 'a', 'nat20', ctx)).enc; + + expect(part(e).status).toBe('conscious'); + expect(part(e).currentHp).toBe(1); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(0); + }); + + test('damage while dying adds one failure', async () => { + let { e, ctx } = await startedAtZero(); + + e = await applyHpChange(e, 'a', 'damage', 1, ctx); + + expect(part(e).status).toBe('dying'); + expect(part(e).deathSaveFailures).toBe(1); + }); + + test('critical damage while dying adds two failures', async () => { + let { e, ctx } = await startedAtZero(); + + e = await applyHpChange(e, 'a', 'damage', 1, { isCriticalHit: true }, ctx); + + expect(part(e).status).toBe('dying'); + expect(part(e).deathSaveFailures).toBe(2); + }); + + test('damage while stable returns to dying and applies failure', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'success', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + expect(part(e).status).toBe('stable'); + + e = await applyHpChange(e, 'a', 'damage', 1, ctx); + + expect(part(e).status).toBe('dying'); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(1); + }); + + test('healing while dying or stable makes conscious and clears counters', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = await applyHpChange(e, 'a', 'heal', 10, ctx); + expect(part(e).status).toBe('conscious'); + expect(part(e).currentHp).toBe(10); + expect(part(e).deathSaveFailures).toBe(0); + + e = await applyHpChange(e, 'a', 'damage', 10, ctx); + e = (await deathSave(e, 'a', 'success', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + e = (await deathSave(e, 'a', 'success', ctx)).enc; + expect(part(e).status).toBe('stable'); + + e = await applyHpChange(e, 'a', 'heal', 5, ctx); + expect(part(e).status).toBe('conscious'); + expect(part(e).currentHp).toBe(5); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(0); + }); + + test('normal healing does not revive dead', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + expect(part(e).status).toBe('dead'); + + try { e = await applyHpChange(e, 'a', 'heal', 10, ctx); } catch (err) {} + + expect(part(e).status).toBe('dead'); + expect(part(e).currentHp).toBe(0); + }); + + test('stabilize sets stable, hp zero, counters zero', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = await stabilizeParticipant(e, 'a', ctx); + + expect(part(e).status).toBe('stable'); + expect(part(e).currentHp).toBe(0); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(0); + }); + + test('negative heal acts as damage and can drop to dying', async () => { + const { ctx } = mockCtx(); + let e = enc([pc('a', { currentHp: 1 })]); + e = await startEncounter(e, ctx); + + e = await applyHpChange(e, 'a', 'heal', -1, ctx); + + expect(part(e).currentHp).toBe(0); + expect(part(e).status).toBe('dying'); + }); + + test('negative damage acts as healing', async () => { + let { e, ctx } = await startedAtZero(); + + e = await applyHpChange(e, 'a', 'damage', -5, ctx); + + expect(part(e).currentHp).toBe(5); + expect(part(e).status).toBe('conscious'); + }); + + test('revive moves dead participant to 0 HP stable/unconscious', async () => { + let { e, ctx } = await startedAtZero(); + + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + e = (await deathSave(e, 'a', 'fail', ctx)).enc; + expect(part(e).status).toBe('dead'); + + e = await reviveParticipant(e, 'a', ctx); + + expect(part(e).currentHp).toBe(0); + expect(part(e).status).toBe('stable'); + expect(part(e).deathSaveSuccesses).toBe(0); + expect(part(e).deathSaveFailures).toBe(0); + expect(part(e).conditions).toContain('unconscious'); + expect(part(e).isActive).toBe(true); + }); + + test('death save outside dying state is rejected or no-op', async () => { + const { ctx } = mockCtx(); + let e = enc([pc('a')]); + e = await startEncounter(e, ctx); + + try { e = (await deathSave(e, 'a', 'fail', ctx)).enc; } catch (err) {} + + expect(part(e).status).toBe('conscious'); + expect(part(e).deathSaveFailures).toBe(0); }); }); diff --git a/shared/tests/turn.logging.test.js b/shared/tests/turn.logging.test.js index dc2e8f1..6da715e 100644 --- a/shared/tests/turn.logging.test.js +++ b/shared/tests/turn.logging.test.js @@ -21,9 +21,9 @@ const { reorderParticipants, endEncounter, } = shared; -function p(id, init) { +function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', - initiative: init, maxHp: 100, currentHp: 100 }); + initiative: init, maxHp: 100, currentHp: 100, ...extra }); } function enc(ps, extra = {}) { return { name:'t', participants:ps, isStarted:false, isPaused:false, @@ -105,11 +105,11 @@ describe('Logging contract: mutating ops', () => { }); test('deathSave logs', async () => { - const e = enc([p('a', 10), p('b', 7), p('c', 3)]); + const e = enc([p('a', 10), p('b', 7, { type: 'character' }), p('c', 3)]); const started = await startEncounter(e, ctx); // 1 log const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs - const r = await deathSave(dying, 'b', 'fail', 1, ctx); // 3 logs - expect(r.status).toBe('pending'); + const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs + expect(r.status).toBe('dying'); expect(storage.logs()).toHaveLength(3); expectLastLogged(storage); }); diff --git a/shared/tests/turn.reorder.test.js b/shared/tests/turn.reorder.test.js index 4fa6151..6a5aef4 100644 --- a/shared/tests/turn.reorder.test.js +++ b/shared/tests/turn.reorder.test.js @@ -23,15 +23,24 @@ function enc(ps) { } describe('reorderParticipants', () => { - test('drag before target (1-list model, pre-combat)', async () => { + test('drag upward inserts before target (1-list model, pre-combat)', async () => { const { ctx } = mockCtx(); const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie let e = enc(ps); // pre-combat, no pointer const newEnc = await reorderParticipants(e, 'c', 'b', ctx); - // drag c before b: remove c → [a,b], insert before b → [a,c,b] + // drag c upward onto b: insert before b → [a,c,b] expect(newEnc.participants.map(p => p.id)).toEqual(['a', 'c', 'b']); }); + test('drag downward inserts after target (1-list model, pre-combat)', async () => { + const { ctx } = mockCtx(); + const ps = [p('a', 20), p('b', 20), p('c', 10)]; // a,b tie + let e = enc(ps); // pre-combat, no pointer + const newEnc = await reorderParticipants(e, 'a', 'b', ctx); + // drag a downward onto b: insert after b → [b,a,c] + expect(newEnc.participants.map(p => p.id)).toEqual(['b', 'a', 'c']); + }); + test('cross-init drag blocked (no-op)', async () => { const { storage, ctx } = mockCtx(); const ps = [p('a', 10), p('b', 20)]; @@ -51,6 +60,19 @@ describe('reorderParticipants', () => { await expect(reorderParticipants(e, 'a', 'zzz', ctx)).rejects.toThrow(); }); + test('paused combat allows reorder across current-turn pointer', async () => { + const { ctx } = mockCtx(); + const ps = [p('a', 20), p('b', 20), p('c', 20), p('d', 20)]; + let e = await startEncounter(enc(ps), ctx); // current=a + e = { ...e, isPaused: true }; + + const newEnc = await reorderParticipants(e, 'd', 'a', ctx); + + expect(newEnc).not.toBe(e); + expect(newEnc.participants.map(p => p.id)).toEqual(['d', 'a', 'b', 'c']); + expect(newEnc.turnOrderIds).toEqual(['d', 'a', 'b', 'c']); + }); + test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', async () => { const { ctx } = mockCtx(); const ps = [p('a', 10), p('b', 20), p('c', 20)]; diff --git a/shared/tests/turn.skip.test.js b/shared/tests/turn.skip.test.js index eb4bae5..8f77ecd 100644 --- a/shared/tests/turn.skip.test.js +++ b/shared/tests/turn.skip.test.js @@ -33,7 +33,7 @@ function setup(ctx) { buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant, buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant, buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant, - buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant, + buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, asNpc: true }).participant, ]; let e = { name: 't', participants: ps, isStarted: false, isPaused: false, diff --git a/shared/turn.js b/shared/turn.js index 331be8c..67eaf06 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -52,6 +52,18 @@ function slotIndexForInit(list, init) { return list.length; } +function withDeathStatusConditions(participant, updates) { + const status = updates.status !== undefined ? updates.status : participant.status; + const current = participant.conditions || []; + let conditions = current; + if (status === 'dying' || status === 'stable') { + conditions = current.includes('unconscious') ? current : [...current, 'unconscious']; + } else if (status === 'conscious' || status === 'dead') { + conditions = current.filter(c => c !== 'unconscious'); + } + return { ...updates, conditions }; +} + const syncTurnOrder = (participants) => ({ turnOrderIds: participants.map(p => p.id), }); @@ -282,13 +294,11 @@ function makeParticipant(opts) { initiative: opts.initiative, maxHp: opts.maxHp, currentHp: opts.currentHp, - isNpc: opts.isNpc || false, conditions: opts.conditions || [], isActive: opts.isActive !== undefined ? opts.isActive : true, - deathSaves: opts.deathSaves || 0, - deathFails: opts.deathFails || 0, - isStable: opts.isStable || false, - isDying: opts.isDying || false, + status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'), + deathSaveSuccesses: opts.deathSaveSuccesses || 0, + deathSaveFailures: opts.deathSaveFailures || 0, }; } @@ -305,13 +315,12 @@ function buildCharacterParticipant(character) { initiative: finalInitiative, maxHp, currentHp: maxHp, - isNpc: false, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, }; } -function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) { +function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) { const initiativeRoll = rollD20(); const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; const finalInitiative = initiativeRoll + modifier; @@ -319,11 +328,10 @@ function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) { return { participant: makeParticipant({ name, - type: 'monster', + type: asNpc ? 'npc' : 'monster', initiative: finalInitiative, maxHp: hp, currentHp: hp, - isNpc: isNpc || false, }), roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, }; @@ -519,85 +527,145 @@ async function toggleParticipantActive(encounter, participantId, ctx) { return commit(encounter, patch, log, ctx); } -async function applyHpChange(encounter, participantId, changeType, amount, ctx) { +async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) { + const ctx = maybeCtx || optionsOrCtx; + const options = maybeCtx ? (optionsOrCtx || {}) : {}; const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); - if (isNaN(amount) || amount === 0) return encounter; // no-op, no write - let newHp = participant.currentHp; - if (changeType === 'damage') newHp = Math.max(0, participant.currentHp - amount); - else if (changeType === 'heal') newHp = Math.min(participant.maxHp, participant.currentHp + amount); - const wasDead = participant.currentHp === 0; - const isDead = newHp === 0; - const wasResurrected = wasDead && newHp > 0; - const updatedParticipants = (encounter.participants || []).map(p => { - if (p.id !== participantId) return p; - const updates = { ...p, currentHp: newHp }; - if (isDead && !wasDead) { - updates.isActive = false; - updates.deathSaves = p.deathSaves || 0; - updates.isDying = false; + if (isNaN(amount) || amount === 0) return encounter; + if (amount < 0) { + amount = Math.abs(amount); + changeType = changeType === 'damage' ? 'heal' : 'damage'; + } + + const oldValues = { + currentHp: participant.currentHp, + status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'), + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + isActive: participant.isActive, + }; + + let updates = {}; + let message = ''; + let logDelta = { amount, from: participant.currentHp }; + const status = oldValues.status; + + if (changeType === 'damage') { + if (participant.currentHp === 0) { + if (status === 'dead') { + if (participant.type === 'monster' && participant.isActive !== false) { + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, isActive: false } : p + ); + const patch = { participants: updatedParticipants }; + const log = { + type: 'deactivate_dead_monster', + participantId, + participantName: participant.name, + message: `${participant.name} marked inactive (dead monster)`, + delta: { status: 'dead', isActive: false }, + undo: { oldValues }, + }; + return commit(encounter, patch, log, ctx); + } + return encounter; + } + const add = options.isCriticalHit ? 2 : 1; + const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add; + if (failures >= 3) { + updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) }; + } else { + updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures }; + } + message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`; + logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures }; + } else if (amount >= participant.currentHp + participant.maxHp) { + updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) }; + message = `Massive damage! ${participant.name} instantly killed`; + logDelta = { ...logDelta, to: 0, status: 'dead', massive: true }; + } else { + const newHp = Math.max(0, participant.currentHp - amount); + if (newHp === 0) { + const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying'; + updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) }; + } else { + updates = { currentHp: newHp, status: 'conscious' }; + } + message = `${participant.name} took ${amount} damage (${participant.currentHp} → ${newHp} HP)`; + logDelta = { ...logDelta, to: newHp, status: updates.status }; } - if (wasResurrected) { - updates.isActive = true; - updates.deathSaves = 0; - updates.isDying = false; - } - return updates; - }); - const hpLine = `${participant.currentHp} → ${newHp} HP`; - const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : ''; - const resurSuffix = wasResurrected ? ' — Revived' : ''; - const message = changeType === 'damage' - ? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}` - : `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`; + } else if (changeType === 'heal') { + if (status === 'dead') return encounter; + const newHp = Math.min(participant.maxHp, Math.max(1, participant.currentHp + amount)); + updates = { currentHp: newHp, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + message = `${participant.name} healed for ${amount} (${participant.currentHp} → ${newHp} HP)`; + logDelta = { ...logDelta, to: newHp, status: 'conscious', deathSavesCleared: true }; + } else { + throw new Error(`Unknown HP change type: ${changeType}`); + } + + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p + ); const patch = { participants: updatedParticipants }; - // undo: inverse amount (damage→heal, heal→damage). expandUndo uses sign. const undoAmount = changeType === 'damage' ? amount : -amount; const log = { type: changeType, participantId, participantName: participant.name, message, - delta: { amount, from: participant.currentHp, to: newHp }, - undo: { amount: undoAmount }, + delta: logDelta, + undo: { amount: undoAmount, oldValues }, }; return commit(encounter, patch, log, ctx); } -// DEATH_SAVE — returns { enc, status, isDying } so caller knows terminal state. -async function deathSave(encounter, participantId, type, n, ctx) { +async function deathSave(encounter, participantId, outcome, ctx) { const participant = (encounter.participants || []).find(p => p.id === participantId); if (!participant) throw new Error('Participant not found.'); - if (type !== 'success' && type !== 'fail') { - throw new Error(`deathSave type must be 'success' or 'fail', got "${type}".`); + const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); + if (statusBefore !== 'dying') throw new Error('Death saves only apply while dying.'); + if (!['success', 'fail', 'nat1', 'nat20'].includes(outcome)) { + throw new Error(`deathSave outcome must be success, fail, nat1, or nat20; got "${outcome}".`); } + + const oldValues = { + currentHp: participant.currentHp, + status: statusBefore, + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + }; + + let updates; + if (outcome === 'nat20') { + updates = { currentHp: 1, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + } else { + const successes = (participant.deathSaveSuccesses || 0) + (outcome === 'success' ? 1 : 0); + const failures = (participant.deathSaveFailures || 0) + (outcome === 'fail' ? 1 : outcome === 'nat1' ? 2 : 0); + if (failures >= 3) updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + else if (successes >= 3) updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + else updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: successes, deathSaveFailures: failures }; + } + const name = participant.name; - const current = type === 'success' ? (participant.deathSaves || 0) : (participant.deathFails || 0); - const next = current === n ? n - 1 : n; - const field = type === 'success' ? 'deathSaves' : 'deathFails'; - const updates = { [field]: next }; - let status = 'pending'; - const newSuccesses = type === 'success' ? next : (participant.deathSaves || 0); - const newFails = type === 'fail' ? next : (participant.deathFails || 0); - if (newSuccesses >= 3) { updates.isStable = true; updates.isDying = false; status = 'stable'; } - else if (newFails >= 3) { updates.isStable = false; updates.isDying = true; status = 'dead'; } - else { updates.isStable = false; updates.isDying = false; } + const message = outcome === 'nat20' ? `Nat 20! ${name} restored to 1 HP, conscious` + : outcome === 'nat1' ? `Nat 1! ${name} takes 2 death save failures` + : updates.status === 'stable' ? `${name} stabilized (3 death saves)` + : updates.status === 'dead' ? `${name} failed 3 death saves — dead` + : `${name} death save ${outcome}`; const updatedParticipants = (encounter.participants || []).map(p => - p.id === participantId ? { ...p, ...updates } : p + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p ); - const message = status === 'stable' ? `${name} stabilized (3 death saves)` - : status === 'dead' ? `${name} failed 3 death saves — dead` - : `${name} death ${type}: ${next}/3`; const patch = { participants: updatedParticipants }; - const oldValues = { [field]: participant[field] || 0, isStable: participant.isStable || false, isDying: participant.isDying || false }; const log = { type: 'death_save', participantId, participantName: name, message, - delta: { type, count: next, status }, + delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures }, undo: { oldValues }, }; const enc = await commit(encounter, patch, log, ctx); - return { enc, status, isDying: status === 'dead' }; + return { enc, status: updates.status }; } async function toggleCondition(encounter, participantId, conditionId, ctx) { @@ -621,6 +689,67 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) { return commit(encounter, patch, log, ctx); } +async function stabilizeParticipant(encounter, participantId, ctx) { + const participant = (encounter.participants || []).find(p => p.id === participantId); + if (!participant) throw new Error('Participant not found.'); + const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); + if (status === 'conscious' || participant.currentHp > 0) throw new Error('Cannot stabilize conscious participant.'); + if (status === 'dead') throw new Error('Cannot stabilize dead participant.'); + if (status === 'stable') return encounter; + + const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0 }; + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p + ); + + const patch = { participants: updatedParticipants }; + const oldValues = { + currentHp: participant.currentHp, + status, + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + }; + const log = { + type: 'stabilize', + participantId, + participantName: participant.name, + message: `${participant.name} stabilized at 0 HP`, + delta: { status: 'stable' }, + undo: { oldValues }, + }; + return commit(encounter, patch, log, ctx); +} + +async function reviveParticipant(encounter, participantId, ctx) { + const participant = (encounter.participants || []).find(p => p.id === participantId); + if (!participant) throw new Error('Participant not found.'); + const status = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'); + if (status !== 'dead') return encounter; + + const updates = { currentHp: 0, status: 'stable', deathSaveSuccesses: 0, deathSaveFailures: 0, isActive: true }; + const updatedParticipants = (encounter.participants || []).map(p => + p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p + ); + + const patch = { participants: updatedParticipants }; + const oldValues = { + currentHp: participant.currentHp, + status, + deathSaveSuccesses: participant.deathSaveSuccesses || 0, + deathSaveFailures: participant.deathSaveFailures || 0, + isActive: participant.isActive, + }; + const log = { + type: 'revive', + participantId, + participantName: participant.name, + message: `${participant.name} revived to 0 HP (stable, unconscious)`, + delta: { status: 'stable', currentHp: 0 }, + undo: { oldValues }, + }; + return commit(encounter, patch, log, ctx); +} + async function reorderParticipants(encounter, draggedId, targetId, ctx) { const participants = [...(encounter.participants || [])]; const dragged = participants.find(p => p.id === draggedId); @@ -629,7 +758,7 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) { if (draggedId === targetId) return encounter; // no-op, no write if (dragged.initiative !== target.initiative) return encounter; // cross-init blocked const draggedIndex = participants.findIndex(p => p.id === draggedId); - if (encounter.isStarted && encounter.currentTurnParticipantId) { + if (encounter.isStarted && !encounter.isPaused && encounter.currentTurnParticipantId) { const pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId); const targetIdx0 = participants.findIndex(p => p.id === targetId); if (pointerIdx >= 0) { @@ -637,9 +766,11 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) { if (crosses(draggedIndex, targetIdx0)) return encounter; // cross-pointer blocked } } + const targetIndex = participants.findIndex(p => p.id === targetId); const [removedItem] = participants.splice(draggedIndex, 1); const newTargetIndex = participants.findIndex(p => p.id === targetId); - participants.splice(newTargetIndex, 0, removedItem); + const insertIndex = draggedIndex < targetIndex ? newTargetIndex + 1 : newTargetIndex; + participants.splice(insertIndex, 0, removedItem); const patch = { participants, ...syncTurnOrder(participants) }; const log = { type: 'reorder', @@ -687,7 +818,7 @@ module.exports = { makeParticipant, buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, addParticipants, updateParticipant, removeParticipant, - toggleParticipantActive, applyHpChange, deathSave, toggleCondition, + toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition, reorderParticipants, endEncounter, activateDisplay, clearDisplay, toggleHidePlayerHp, }; diff --git a/src/App.js b/src/App.js index 350435a..fff1da7 100644 --- a/src/App.js +++ b/src/App.js @@ -123,6 +123,8 @@ const { toggleParticipantActive: combatToggleActive, applyHpChange: combatApplyHpChange, deathSave: combatDeathSave, + stabilizeParticipant, + reviveParticipant, toggleCondition: combatToggleCondition, reorderParticipants, } = shared; @@ -550,7 +552,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { const [initiative, setInitiative] = useState(participant.initiative); const [currentHp, setCurrentHp] = useState(participant.currentHp); const [maxHp, setMaxHp] = useState(participant.maxHp); - const [isNpc, setIsNpc] = useState(participant.type === 'monster' ? (participant.isNpc || false) : false); + const [asNpc, setAsNpc] = useState(participant.type === 'npc'); const handleSubmit = (e) => { e.preventDefault(); @@ -559,7 +561,7 @@ function EditParticipantModal({ participant, onClose, onSave }) { initiative: parseInt(initiative, 10), currentHp: parseInt(currentHp, 10), maxHp: parseInt(maxHp, 10), - isNpc: participant.type === 'monster' ? isNpc : false, + type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type, }); }; @@ -605,13 +607,13 @@ function EditParticipantModal({ participant, onClose, onSave }) { /> - {participant.type === 'monster' && ( + {(participant.type === 'monster' || participant.type === 'npc') && (
setIsNpc(e.target.checked)} + checked={asNpc} + onChange={(e) => setAsNpc(e.target.checked)} className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500" />