Implement D&D 5e death-save state machine and cleanup combat ordering

- Add status-driven death-save model:
  - status: conscious | dying | stable | dead
  - deathSaveSuccesses/deathSaveFailures as display counters
  - remove old death-save fields as source of truth
- Add death-save actions:
  - success, fail, nat1, nat20
  - stabilize participant
  - revive dead participant to 0 HP, stable, unconscious
- Apply 5e HP transition rules:
  - characters and NPCs at 0 HP become dying
  - stable/dying participants gain unconscious condition
  - healing clears death saves and returns conscious
  - massive damage kills
  - non-NPC monsters at 0 HP become dead and inactive
- Split NPCs from monsters with type="npc":
  - NPCs use death saves like characters
  - NPCs remain DM-controlled/monster-colored in UI
  - new NPCs no longer persist isNpc flag
- Keep dead PCs/NPCs in encounter and initiative until DM removes or deactivates
- Allow DM active/inactive toggle for dead participants
- Hide inactive participants from player display
- Improve DM and player death-state UI:
  - show Dying/Dead/Unconscious states consistently
  - add revive button for dead participants
  - distinguish dead visual from inactive visual in DM view
- Fix initiative drag/reorder behavior:
  - downward drag now changes order instead of no-op
  - paused combat can reorder across current turn pointer
  - turnOrderIds stay synced to participants order
- Persist campaign collapse state in localStorage
- Update death-save docs and encounter builder docs
- Add/expand tests for:
  - death saves and HP transitions
  - dead/active/inactive behavior
  - NPC death-save behavior
  - player display visibility
  - drag reorder semantics
  - logging expectations

Tests:
- npm run test:all
- app: 94 passed
- shared: 158 passed, 5 skipped
- server: 32 passed
This commit is contained in:
david raistrick
2026-07-06 16:48:50 -04:00
parent 2569cc4497
commit 1d4ec873dc
22 changed files with 1617 additions and 531 deletions
+288
View File
@@ -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
+47 -10
View File
@@ -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.
+142
View File
@@ -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)
+1 -1
View File
@@ -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
+28 -18
View File
@@ -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.
+133
View File
@@ -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: <type-specific inverse, id-based> // 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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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) {
+3 -2
View File
@@ -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 },
],
};
}
+23 -30
View File
@@ -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 () => {
+5 -5
View File
@@ -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
+50 -63
View File
@@ -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);
});
});
+306 -64
View File
@@ -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 () => {
function enc(participants, extra = {}) {
return {
id: 'e1',
name: 'death-save-test',
participants,
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
...extra,
};
}
async function startedAtZero() {
const { ctx } = mockCtx();
// Character at 0hp. Roll 3 successful death saves.
let e = enc([p('a')]);
let e = enc([pc('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // 0hp
expect(e.participants[0].currentHp).toBe(0);
const originalTurnOrderIds = [...e.turnOrderIds];
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
return { e, ctx, originalTurnOrderIds };
}
// 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;
const part = e => e.participants.find(p => p.id === 'a');
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);
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);
});
});
+5 -5
View File
@@ -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);
});
+24 -2
View File
@@ -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)];
+1 -1
View File
@@ -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,
+195 -64
View File
@@ -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';
}
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}`;
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 };
}
} 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,
};
+124 -78
View File
@@ -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 }) {
/>
</div>
</div>
{participant.type === 'monster' && (
{(participant.type === 'monster' || participant.type === 'npc') && (
<div className="flex items-center">
<input
type="checkbox"
id="editIsNpc"
checked={isNpc}
onChange={(e) => 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"
/>
<label htmlFor="editIsNpc" className="ml-2 block text-sm text-stone-300">
@@ -910,7 +912,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
const [manualInitiative, setManualInitiative] = useState('');
const [isNpc, setIsNpc] = useState(false);
const [asNpc, setAsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null);
@@ -940,7 +942,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
} else {
setMaxHp(DEFAULT_MAX_HP);
}
setIsNpc(false);
setAsNpc(false);
} else if (participantType === 'monster') {
setMaxHp(DEFAULT_MAX_HP);
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
@@ -956,7 +958,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const initiativeRoll = rollD20();
let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
let participantIsNpc = false;
let participantAsNpc = false;
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
@@ -974,25 +976,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
modifier = character.defaultInitMod || 0;
} else {
modifier = parseInt(monsterInitMod, 10) || 0;
participantIsNpc = isNpc;
participantAsNpc = asNpc;
}
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
const newParticipant = {
id: generateId(),
name: nameToAdd,
type: participantType,
type: participantType === 'monster' && participantAsNpc ? 'npc' : participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: computedInitiative,
maxHp: currentMaxHp,
currentHp: currentMaxHp,
isNpc: participantType === 'monster' ? participantIsNpc : false,
conditions: [],
isActive: true,
deathSaves: 0,
deathFails: 0,
isStable: false,
isDying: false,
status: 'conscious',
deathSaveSuccesses: 0,
deathSaveFailures: 0,
};
try {
@@ -1003,7 +1003,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
roll: manualInit ? null : initiativeRoll,
mod: manualInit ? null : modifier,
total: computedInitiative,
type: participantIsNpc ? 'NPC' : participantType,
type: participantAsNpc ? 'NPC' : participantType,
manual: manualInit,
});
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
@@ -1012,7 +1012,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
setMaxHp(DEFAULT_MAX_HP);
setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setIsNpc(false);
setAsNpc(false);
setManualInitiative('');
} catch (err) {
showToast("Failed to add participant. Please try again."); }
@@ -1041,11 +1041,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
currentHp: char.defaultMaxHp || DEFAULT_MAX_HP,
conditions: [],
isActive: true,
isNpc: false,
deathSaves: 0,
deathFails: 0,
isStable: false,
isDying: false,
status: 'conscious',
deathSaveSuccesses: 0,
deathSaveFailures: 0,
};
});
@@ -1124,18 +1122,34 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}
};
const handleDeathSaveChange = async (participantId, type, saveNumber) => {
const handleDeathSaveChange = async (participantId, outcome) => {
if (!db) return;
try {
const { isDying } = await combatDeathSave(encounter, participantId, type, saveNumber, buildCtx(encounterPath));
if (isDying) {
setTimeout(async () => {
const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
await storage.updateDoc(encounterPath, { participants: finalParticipants });
}, 2000);
}
await combatDeathSave(encounter, participantId, outcome, buildCtx(encounterPath));
} catch (err) {
// fall through silently
showToast(err.message);
}
};
const handleNat20 = async (participantId) => handleDeathSaveChange(participantId, 'nat20');
const handleNat1 = async (participantId) => handleDeathSaveChange(participantId, 'nat1');
const handleStabilize = async (participantId) => {
if (!db) return;
try {
await stabilizeParticipant(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
const handleRevive = async (participantId) => {
if (!db) return;
try {
await reviveParticipant(encounter, participantId, buildCtx(encounterPath));
} catch (err) {
showToast(err.message);
}
};
@@ -1246,7 +1260,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onChange={(e) => {
setParticipantType(e.target.value);
setSelectedCharacterId('');
setIsNpc(false);
setAsNpc(false);
}}
className="mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 rounded text-white"
>
@@ -1310,12 +1324,12 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
<div className="md:col-span-2 flex items-center pt-5">
<input
type="checkbox"
id="isNpc"
checked={isNpc}
onChange={(e) => setIsNpc(e.target.checked)}
id="asNpc"
checked={asNpc}
onChange={(e) => setAsNpc(e.target.checked)}
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500"
/>
<label htmlFor="isNpc" className="ml-2 block text-sm text-stone-300">
<label htmlFor="asNpc" className="ml-2 block text-sm text-stone-300">
Is NPC?
</label>
</div>
@@ -1377,8 +1391,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
? `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Set initiative ${lastRollDetails.total}`
: `${lastRollDetails.name} (${lastRollDetails.type === 'character' ? 'Character' : lastRollDetails.type === 'monster' ? 'Monster' : lastRollDetails.type === 'npc' ? 'NPC' : lastRollDetails.type}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
}
</p>
)}
@@ -1389,12 +1403,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{sortedParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative));
const participantDisplayType = p.type === 'monster' ? (p.isNpc ? 'NPC' : 'Monster') : 'Character';
const participantDisplayType = p.type === 'npc' ? 'NPC' : (p.type === 'monster' ? 'Monster' : 'Character');
const hasDeathSaves = p.type === 'character' || p.type === 'npc';
let bgColor = p.type === 'character' ? 'bg-indigo-950' : (p.isNpc ? 'bg-stone-700' : 'bg-[#8e351c]');
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const isDead = p.currentHp === 0;
const participantStatus = p.status || (p.currentHp === 0 ? (p.type === 'monster' ? 'dead' : 'dying') : 'conscious');
const isZeroHp = p.currentHp === 0;
const isDead = participantStatus === 'dead';
return (
<li
@@ -1404,7 +1421,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive && !isDead ? 'opacity-50' : ''} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
className={`p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''}`}
>
<div className="flex-1 flex items-start sm:items-center">
{isDraggable && (
@@ -1416,14 +1433,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
)}
<div className="flex-1">
<p className="font-semibold text-lg text-white">
{isDead && <span className="mr-2"></span>}
{isZeroHp && <span className="mr-2"></span>}
{p.name} <span className="text-xs">({participantDisplayType})</span>
{isCurrentTurn && !encounter.isPaused && (
<span className="ml-2 px-2 py-0.5 bg-yellow-400 text-black text-xs font-bold rounded-full inline-flex items-center">
<Zap size={12} className="mr-1" /> CURRENT
</span>
)}
{isDead && <span className="ml-2 text-xs text-red-300 font-semibold">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
{hasDeathSaves && participantStatus === 'dying' && <span className="ml-2 text-xs text-red-300 font-semibold animate-pulse">(Dying)</span>}
{hasDeathSaves && participantStatus === 'stable' && (p.conditions || []).includes('unconscious') && <span className="ml-2 text-xs text-emerald-300 font-semibold">(Unconscious)</span>}
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
</p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
<span className="inline-flex items-center gap-1">
@@ -1447,41 +1466,60 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
<span>HP: {p.currentHp}/{p.maxHp}</span>
</div>
{/* Death saves - D&D 5e: successes + fails separate */}
{isDead && encounter.isStarted && p.type === 'character' && (
{participantStatus === 'dead' && (
<button onClick={() => handleRevive(p.id)} className="mt-2 inline-flex items-center gap-1 self-start px-2.5 py-1 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive to 0 HP, stable and unconscious">
<HeartPulse size={14} /> Revive
</button>
)}
{/* Death saves - D&D 5e status state machine */}
{hasDeathSaves && encounter.isStarted && participantStatus !== 'conscious' && (
<div className="mt-2 flex flex-col space-y-1">
{p.isStable && (
<div className="text-xs text-emerald-300 font-medium">Stabilized</div>
)}
{p.isDying && !p.isStable && (
<div className="text-xs text-red-400 font-medium animate-pulse">Dying</div>
)}
{participantStatus === 'dying' && (
<>
<div className="flex items-center space-x-2">
<span className="text-xs text-emerald-300 font-medium w-10">Saves:</span>
{[1, 2, 3].map(saveNum => (
<button
<span
key={saveNum}
onClick={() => handleDeathSaveChange(p.id, 'success', saveNum)}
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathSaves || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600 hover:border-emerald-400'}`}
title={`Success ${saveNum}`}
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveSuccesses || 0) >= saveNum ? 'bg-emerald-600 border-emerald-500' : 'bg-stone-800 border-stone-600'}`}
title={`Success pip ${saveNum}`}
>
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm"></span>}
</button>
{(p.deathSaveSuccesses || 0) >= saveNum && <span className="text-white text-sm"></span>}
</span>
))}
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-red-300 font-medium w-10">Fails:</span>
{[1, 2, 3].map(failNum => (
<button
<span
key={failNum}
onClick={() => handleDeathSaveChange(p.id, 'fail', failNum)}
className={`w-6 h-6 rounded border-2 transition-all ${(p.deathFails || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600 hover:border-red-400'} ${failNum === 3 && (p.deathFails || 0) >= 3 ? 'animate-pulse' : ''}`}
title={`Fail ${failNum}`}
className={`w-6 h-6 rounded border-2 flex items-center justify-center ${(p.deathSaveFailures || 0) >= failNum ? 'bg-red-600 border-red-500' : 'bg-stone-800 border-stone-600'}`}
title={`Fail pip ${failNum}`}
>
{(p.deathFails || 0) >= failNum && <span className="text-white text-sm"></span>}
</button>
{(p.deathSaveFailures || 0) >= failNum && <span className="text-white text-sm"></span>}
</span>
))}
</div>
<div className="flex gap-2 mt-1">
<button onClick={() => handleDeathSaveChange(p.id, 'success')} className="px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Death save success">
Success
</button>
<button onClick={() => handleDeathSaveChange(p.id, 'fail')} className="px-2 py-1 text-xs rounded bg-red-700 hover:bg-red-600 text-white" title="Death save failure">
Fail
</button>
<button onClick={() => handleNat1(p.id)} className="px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-700 text-white" title="Natural 1: +2 failures">
<AlertTriangle size={12} /> Nat1
</button>
<button onClick={() => handleNat20(p.id)} className="px-2 py-1 text-xs rounded bg-yellow-600 hover:bg-yellow-500 text-white" title="Natural 20: 1 HP + conscious">
<Zap size={12} /> Nat20
</button>
<button onClick={() => handleStabilize(p.id)} className="px-2 py-1 text-xs rounded bg-emerald-700 hover:bg-emerald-600 text-white" title="Stabilize at 0 HP">
<HeartPulse size={12} /> Stabilize
</button>
</div>
</>
)}
</div>
)}
@@ -1588,7 +1626,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</button>
</div>
)}
{!isDead && (
<button
onClick={() => toggleParticipantActive(p.id)}
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
@@ -1596,7 +1633,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
>
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
</button>
)}
<button
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`}
@@ -2160,7 +2196,15 @@ function AdminView({ userId }) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [campaignsCollapsed, setCampaignsCollapsed] = useState(false);
const [campaignsCollapsed, setCampaignsCollapsed] = useState(() => {
try { return localStorage.getItem('ttrpg.campaignsCollapsed') === 'true'; }
catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('ttrpg.campaignsCollapsed', String(campaignsCollapsed)); }
catch {}
}, [campaignsCollapsed]);
useEffect(() => {
if (campaignsData && db) {
@@ -2597,7 +2641,7 @@ function DisplayView() {
// 1-list model: participants[] IS the display order (DM drag = source of
// truth). Do NOT re-sort by initiative — that diverges from AdminView /
// turnOrderIds after any cross-init drag (BUG-15).
participantsToRender = participants.filter(p => p.isActive || p.type !== 'monster');
participantsToRender = participants.filter(p => p.isActive !== false);
}
const displayStyles = campaignBackgroundUrl
@@ -2654,10 +2698,10 @@ function DisplayView() {
<div className="space-y-4 max-w-3xl mx-auto">
{participantsToRender.map(p => {
const isDead = p.currentHp === 0;
const isDying = p.isDying || false;
let participantBgColor = p.type === 'monster'
? (p.isNpc ? 'bg-stone-800' : 'bg-[#8e351c]')
const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
const isZeroHp = p.currentHp === 0;
let participantBgColor = p.type === 'monster' || p.type === 'npc'
? 'bg-[#8e351c]'
: 'bg-indigo-950';
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
@@ -2672,18 +2716,20 @@ function DisplayView() {
<div
key={p.id}
ref={isCurrentTurn ? currentParticipantRef : null}
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${isDead ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')} ${isDying ? 'animate-death-dissolve' : ''}`}
className={`p-4 md:p-6 rounded-lg shadow-lg transition-all ${participantBgColor} ${status === 'dead' ? 'opacity-40 grayscale' : (!p.isActive ? 'opacity-40 grayscale' : '')}`}
>
<div className="flex justify-between items-center mb-2">
<h3
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : (p.isNpc ? 'text-stone-100' : 'text-white'))}`}
className={`text-2xl md:text-3xl font-bold font-cinzel ${isCurrentTurn ? 'text-white' : (p.type === 'character' ? 'text-amber-100' : 'text-white')}`}
>
{isDead && <span className="mr-2"></span>}
{isZeroHp && <span className="mr-2"></span>}
{p.name}
{isCurrentTurn && (
<span className="text-yellow-300 animate-pulse ml-2">(Current)</span>
)}
{isDead && <span className="text-red-300 text-lg ml-2">{p.type === 'character' ? '(Unconscious)' : '(Dead)'}</span>}
{status === 'dying' && <span className="text-red-300 text-lg ml-2">(Dying)</span>}
{status === 'stable' && (p.conditions || []).includes('unconscious') && <span className="text-emerald-300 text-lg ml-2">(Unconscious)</span>}
{status === 'dead' && <span className="text-red-300 text-lg ml-2">(Dead)</span>}
</h3>
<span
className={`text-xl md:text-2xl font-semibold ${isCurrentTurn ? 'text-green-200' : 'text-stone-200'}`}
@@ -2696,7 +2742,7 @@ function DisplayView() {
<div className="flex justify-between items-center">
<div className="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
<div
className={`h-full rounded-full transition-all ${isDead ? 'bg-red-900' : (p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500'))}`}
className={`h-full rounded-full transition-all ${isZeroHp ? 'bg-red-900' : (p.currentHp <= p.maxHp / 4 ? 'bg-red-500' : (p.currentHp <= p.maxHp / 2 ? 'bg-yellow-500' : 'bg-green-500'))}`}
style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}
></div>
{p.type !== 'monster' && (
@@ -2731,7 +2777,7 @@ function DisplayView() {
</div>
)}
{!p.isActive && !isDead && (
{!p.isActive && !isZeroHp && (
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
)}
</div>
+15 -12
View File
@@ -75,8 +75,8 @@ function addCharacterViaUI(name, maxHp, initMod) {
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
}
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
enc = await addParticipant(enc, participant, ctx);
}
async function addCharacterParticipant(charName) {
@@ -140,10 +140,10 @@ async function removeParticipantByName(name) {
if (!p) throw new Error(`no remove target: ${name}`);
enc = await removeParticipant(enc, p.id, ctx);
}
async function deathSaveAction(name, type, saveNum) {
async function deathSaveAction(name, outcome) {
const p = any(name);
if (!p) throw new Error(`no deathsave target: ${name}`);
const r = await combatDeathSave(enc, p.id, type, saveNum, ctx);
const r = await combatDeathSave(enc, p.id, outcome, ctx);
enc = r.enc;
}
async function dragTie(draggedName, targetName) {
@@ -221,19 +221,22 @@ test('full 100-round combat scenario (shared, no React)', async () => {
}
if (r === 25 && turnInRound === 0) {
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', 99));
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1));
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
if (r === 50 && turnInRound === 0) {
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', 99));
await record(`r${r} deathSave Cleric x3 (isDying)`, async () => {
await deathSaveAction('Cleric', 'fail', 1);
await deathSaveAction('Cleric', 'fail', 2);
await deathSaveAction('Cleric', 'fail', 3);
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail');
});
const cl = any('Cleric');
if (cl && cl.isDying) await record(`r${r} remove dying Cleric`, () => removeParticipantByName('Cleric'));
if (cl) {
expect(cl.status).toBe('dead');
expect(enc.participants.map(p => p.name)).toContain('Cleric');
}
}
if (r % 30 === 0 && turnInRound === 1) {
+52 -3
View File
@@ -4,29 +4,46 @@
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { render, waitFor, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
function seedActiveDisplay() {
function seedActiveDisplay(participants = []) {
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
MOCK_DB.set(encounterPath, { name: 'Enc', participants, isStarted: true, round: 1, currentTurnParticipantId: participants[0]?.id || null });
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
}
function participant(status) {
return {
id: status,
name: status === 'dying' ? 'Rogue' : status,
type: 'character',
initiative: 18,
maxHp: 160,
currentHp: status === 'conscious' ? 10 : 0,
isActive: true,
status,
deathSaveSuccesses: 0,
deathSaveFailures: 0,
conditions: status === 'stable' || status === 'dying' ? ['unconscious'] : [],
};
}
describe('DisplayView characterization', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
resetAdapterCalls();
Element.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
window.history.replaceState({}, '', '/');
@@ -57,4 +74,36 @@ describe('DisplayView characterization', () => {
expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 });
});
test('DisplayView shows Dying for dying character with Unconscious condition', async () => {
seedActiveDisplay([participant('dying')]);
render(<App />);
await waitFor(() => expect(screen.getByText('Rogue')).toBeInTheDocument());
expect(screen.getByText(/Dying/i)).toBeInTheDocument();
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
});
test('DisplayView shows Stable as Unconscious, and Dead as Dead', async () => {
seedActiveDisplay([participant('stable'), participant('dead')]);
render(<App />);
await waitFor(() => expect(screen.getByText('stable')).toBeInTheDocument());
expect(screen.getAllByText(/Unconscious/i).length).toBeGreaterThan(0);
expect(screen.queryByText('(Stable)')).not.toBeInTheDocument();
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
});
test('DisplayView hides inactive participants for all types', async () => {
seedActiveDisplay([
{ ...participant('conscious'), id: 'active-pc', name: 'Active PC', isActive: true },
{ ...participant('conscious'), id: 'inactive-pc', name: 'Inactive PC', isActive: false },
{ id: 'inactive-monster', name: 'Inactive Monster', type: 'monster', initiative: 10, maxHp: 10, currentHp: 10, isActive: false, status: 'conscious', conditions: [] },
]);
render(<App />);
await waitFor(() => expect(screen.getByText('Active PC')).toBeInTheDocument());
expect(screen.queryByText('Inactive PC')).not.toBeInTheDocument();
expect(screen.queryByText('Inactive Monster')).not.toBeInTheDocument();
});
});
+139 -139
View File
@@ -1,174 +1,174 @@
// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save.
// Logs + death-save UI characterization. Lock log writes, undo, clear, and death-save flow.
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import { getCalls } from '../__mocks__/firebase/_mock-db';
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
function findLogCalls() {
return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs'));
function findCalls(fn, includes) {
return getCalls().filter(c => c.fn === fn && (!includes || c.path.includes(includes)));
}
function lastEncCall() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
const calls = findCalls('updateDoc', '/encounters/');
return calls[calls.length - 1];
}
// Navigate to /logs view. App reads pathname at mount; must re-render with path preset.
import { render } from '@testing-library/react';
import App from '../App';
async function goToLogs() {
// unmount current tree isn't needed; App checks pathname in useEffect.
// Re-render a fresh App instance in same container.
window.history.replaceState({}, '', '/logs');
document.body.innerHTML = '';
render(<App />);
await waitFor(() => screen.getByText(/Combat Log/i));
async function addCharacterToEncounter(name = 'Hero', hp = 10) {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI(`DS-${name}`);
await selectCampaignByName(`DS-${name}`);
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
await createEncounterViaUI(`Enc-${name}`);
await selectEncounterByName(`Enc-${name}`);
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === name);
await startCombatViaUI();
return { charId };
}
async function dropFirstParticipantToZero(hp = 10) {
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: String(hp) } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
}
describe('Logs -> Firebase', () => {
test('logAction: addDoc to logs collection on combat start', async () => {
await setupReady('LogCamp', 'LogEnc');
await addMonsterViaUI('Mob', 10, 2);
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('LogCamp');
await selectCampaignByName('LogCamp');
await createEncounterViaUI('LogEnc');
await selectEncounterByName('LogEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
expect(logCall.data).toHaveProperty('message');
expect(logCall.data).toHaveProperty('ts');
expect(logCall.data.message).toMatch(/Combat started/);
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
expect(findCalls('addDoc', '/logs').length).toBeGreaterThan(0);
});
test('logAction: includes lean undo data', async () => {
await setupReady('UndoCamp', 'UndoEnc');
await addMonsterViaUI('Mob', 10, 2);
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('UndoDataCamp');
await selectCampaignByName('UndoDataCamp');
await createEncounterViaUI('UndoDataEnc');
await selectEncounterByName('UndoDataEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message)));
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message));
expect(logCall.data.undo).toBeTruthy();
expect(logCall.data.undo).toHaveProperty('isStarted', false);
expect(logCall.data.undo).toHaveProperty('round', 0);
expect(logCall.data.type).toBe('start_encounter');
expect(logCall.data).toHaveProperty('snapshot');
expect(logCall.data.undone).toBe(false);
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
const log = findCalls('addDoc', '/logs').pop().data;
expect(log).toHaveProperty('undo');
});
test('clearLogs: writeBatch deletes all log docs', async () => {
const { renderApp } = require('./testHelpers');
// seed a log entry via combat start
await setupReady('ClearCamp', 'ClearEnc');
await addMonsterViaUI('Mob', 10, 2);
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('ClearLogs');
await selectCampaignByName('ClearLogs');
await createEncounterViaUI('ClearLogsEnc');
await selectEncounterByName('ClearLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0);
await goToLogs();
const clearBtn = await screen.findByRole('button', { name: /Clear Log/i });
fireEvent.click(clearBtn);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
return batchDeletes.length > 0;
});
const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs'));
expect(batchDeletes.length).toBeGreaterThan(0);
expect(true).toBe(true);
});
test('undo: tx batch marks log undone + updates encounter', async () => {
// seed log via combat start
await setupReady('UndoFlowCamp', 'UndoFlowEnc');
await addMonsterViaUI('Mob', 10, 2);
await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0);
await goToLogs();
const undoBtns = await screen.findAllByRole('button', { name: /Undo/i });
fireEvent.click(undoBtns[0]);
// tx undo = batch.update on log (undone:true) + encounter
await waitFor(() => {
const und = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
return und;
});
const markUndone = getCalls().find(c => c.fn === 'batch.update' && c.path.includes('/logs/') && c.data.undone === true);
expect(markUndone.data.undone).toBe(true);
const encUndo = getCalls().filter(c => c.fn === 'batch.update' && c.path.includes('/encounters/'));
expect(encUndo.length).toBeGreaterThan(0);
});
});
describe('DeathSave -> Firebase', () => {
test('first death save: updateDoc increments deathSaves', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
const { within } = require('@testing-library/react');
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await renderApp();
await createCampaignViaUI('DSC2');
await selectCampaignByName('DSC2');
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => {
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
return c;
});
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
await createEncounterViaUI('DSEnc2');
await selectEncounterByName('DSEnc2');
// switch to character type and add
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Hero');
await createCampaignViaUI('UndoLogs');
await selectCampaignByName('UndoLogs');
await createEncounterViaUI('UndoLogsEnc');
await selectEncounterByName('UndoLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI();
// damage to 0
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
// death save buttons appear
const save1 = screen.getByTitle('Success 1');
fireEvent.click(save1);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1);
expect(lastEncCall().data.participants[0].deathSaves).toBe(1);
expect(true).toBe(true);
});
});
test('third death save: marks isDying true', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers');
const { within } = require('@testing-library/react');
await renderApp();
await createCampaignViaUI('DSDie');
await selectCampaignByName('DSDie');
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => {
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1);
return c;
describe('DeathSave -> Firebase/UI', () => {
test('drop to 0 shows death-save controls and status dying', async () => {
await addCharacterToEncounter('Hero', 10);
await dropFirstParticipantToZero(10);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dying');
expect(lastEncCall().data.participants[0].status).toBe('dying');
expect(screen.getByRole('button', { name: /^Fail$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Success$/i })).toBeInTheDocument();
});
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id;
await createEncounterViaUI('DSEncDie');
await selectEncounterByName('DSEncDie');
test('third Fail action immediately shows Dead and keeps participant', async () => {
await addCharacterToEncounter('Martyr', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
const p = lastEncCall().data.participants[0];
expect(p.status).toBe('dead');
expect(p.deathSaveFailures).toBe(0);
expect(lastEncCall().data.participants).toHaveLength(1);
expect(screen.getAllByText('Martyr').length).toBeGreaterThan(0);
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
});
test('third Success action immediately shows Stable and hides controls', async () => {
await addCharacterToEncounter('StableHero', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 1);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 2);
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'stable');
expect(lastEncCall().data.participants[0].deathSaveSuccesses).toBe(0);
expect(screen.getAllByText(/Stable/i).length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: /^Success$/i })).not.toBeInTheDocument();
});
test('Nat20 restores 1 HP conscious and hides death-save UI', async () => {
await addCharacterToEncounter('Lucky', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /Nat20/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'conscious');
const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(1);
expect(p.deathSaveSuccesses).toBe(0);
expect(p.deathSaveFailures).toBe(0);
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
});
test('dead character remains excluded from add dropdown because still in encounter', async () => {
const { getParticipantForm } = require('./testHelpers');
await addCharacterToEncounter('StillHere', 10);
await dropFirstParticipantToZero(10);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } });
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Martyr');
await startCombatViaUI();
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } });
fireEvent.click(screen.getByTitle('Damage'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.click(screen.getByTitle('Fail 1'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 1);
fireEvent.click(screen.getByTitle('Fail 2'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathFails === 2);
fireEvent.click(screen.getByTitle('Fail 3'));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true);
expect(lastEncCall().data.participants[0].isDying).toBe(true);
expect(lastEncCall().data.participants[0].deathFails).toBe(3);
expect(screen.getAllByText('StillHere').length).toBeGreaterThan(0);
expect(form.queryByRole('option', { name: 'StillHere' })).not.toBeInTheDocument();
});
});
+11 -9
View File
@@ -29,8 +29,9 @@ describe('Participant -> Firebase', () => {
const p = call.data.participants[0];
expect(p).toMatchObject({
name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7,
isNpc: false, isActive: true, deathSaves: 0, isDying: false, conditions: [],
isActive: true, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0, conditions: [],
});
expect(p).not.toHaveProperty('isNpc');
expect(p).toHaveProperty('id');
expect(p).toHaveProperty('initiative');
});
@@ -43,7 +44,7 @@ describe('Participant -> Firebase', () => {
expect(p.initiative).toBeLessThanOrEqual(23);
});
test('addMonster as NPC: isNpc true', async () => {
test('addMonster as NPC: type npc', async () => {
await setupReady();
const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
@@ -53,7 +54,8 @@ describe('Participant -> Firebase', () => {
const p = lastEncCall()?.data?.participants?.[0];
return p && p.name === 'Guard';
});
expect(lastEncCall().data.participants[0].isNpc).toBe(true);
expect(lastEncCall().data.participants[0].type).toBe('npc');
expect(lastEncCall().data.participants[0]).not.toHaveProperty('isNpc');
});
test('deleteParticipant: updateDoc removes participant', async () => {
@@ -83,7 +85,7 @@ describe('Participant -> Firebase', () => {
expect(lastEncCall().data.participants[0].currentHp).toBe(7);
});
test('damage to 0 deactivates participant', async () => {
test('damage to 0 marks non-NPC monster dead and inactive', async () => {
await setupReady();
await addMonsterViaUI('Doom', 5, 0);
await startCombatViaUI();
@@ -92,10 +94,11 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false);
});
test('heal revives from 0 (reactivates, resets death saves)', async () => {
test('normal heal does not revive dead non-NPC monster', async () => {
await setupReady();
await addMonsterViaUI('Revive', 5, 0);
await startCombatViaUI();
@@ -104,11 +107,10 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
fireEvent.click(screen.getByTitle(/Heal/i));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 3);
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(3);
expect(p.isActive).toBe(true);
expect(p.deathSaves).toBe(0);
expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
});
test('toggleCondition: updateDoc adds condition to array', async () => {