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 ## 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 ### TEST GAP: current branch changes need focused coverage
- Storage `where()` contract: firebase + server adapters should honor `[where('encounterPath','==',x), orderBy('ts','desc'), limit(n)]`. - 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. - 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. - 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 ### 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 case-insensitive. maxLength 40. Combat + replay tests prove arbitrary
string ids survive round-trip. string ids survive round-trip.
### Death saves: D&D 5e model (DONE) ### Death saves: D&D 5e status model (DONE)
- Separate success/fail tracking. deathSave(enc, id, type, n), - `status` is source of truth: conscious, dying, stable, dead.
type='success'|'fail'. Fields: deathSaves, deathFails, isStable, isDying. - Characters and NPCs use death saves; monsters skip death saves and become dead/inactive at 0 HP.
3 success=stable, 3 fail=dead. Returns {patch, log, status, isDying}. - Death-save actions: Success, Fail, Nat1, Nat20, Stabilize.
Old single-counter broken (successes missing). Old data lost (feature - Revive: dead → 0 HP, stable/unconscious, active.
never worked in prod). UI: green ✓ + red ✕ rows, Stabilized/Dying badges. - 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) ### FEAT-3: initiative first-class entry (DONE)
- Initiative field at add-char, add-monster, edit participant. - 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 ### BUG-18: stale comments reference deleted memory adapter
- FIXED. - FIXED.
### FEAT-1: Dead participants stay in turn order ### FEAT-1: Dead characters/NPCs stay in turn order
- DONE --- applyHpChange no longer flips isActive on death. Dead stay in - DONE --- character/NPC death does not auto-remove. DM controls inactive/remove.
rotation, nextTurn visits them, PCs get death-save turn. - Non-NPC monsters still become inactive automatically at death.
### combat.scenario 100 rounds not turns ### combat.scenario 100 rounds not turns
- DONE --- loops by actual round-wrap count. - 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) 1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN) 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 4. turnOrder no dup ids
5. turnOrder ids all active 5. turnOrder ids all active
6. currentTurn valid + active 6. currentTurn valid + active
+28 -18
View File
@@ -53,16 +53,16 @@ Object in `encounter.participants[]`:
|---|---|---| |---|---|---|
| `id` | string | `generateId()` | | `id` | string | `generateId()` |
| `name` | string | | | `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 | | `originalCharacterId` | string\|null | links back to campaign character if type=character |
| `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. | | `initiative` | int | rolled once at add (`rollD20() + mod`). Stored value, not re-derived. |
| `maxHp` | int | | | `maxHp` | int | |
| `currentHp` | int | 0 = dead/dying | | `currentHp` | int | 0 = dead/dying/stable by `status` |
| `isNpc` | bool | monster flagged NPC (display color, no death saves) |
| `conditions` | array | condition ids from `CONDITIONS` list | | `conditions` | array | condition ids from `CONDITIONS` list |
| `isActive` | bool | in turn rotation? false = skipped by nextTurn | | `isActive` | bool | in turn rotation? false = skipped by nextTurn |
| `deathSaves` | int | PC only, 0-3 fails | | `status` | `'conscious'` \| `'dying'` \| `'stable'` \| `'dead'` | death-save source of truth |
| `isDying` | bool | death animation flag (player display) | | `deathSaveSuccesses` | int | character/NPC only, 0-3 successes |
| `deathSaveFailures` | int | character/NPC only, 0-3 failures |
## Build flow (UI) ## Build flow (UI)
@@ -99,7 +99,7 @@ ParticipantManager section. Two paths:
- **Monster Name** (`placeholder: "e.g., Dire Wolf"`) - **Monster Name** (`placeholder: "e.g., Dire Wolf"`)
- **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2) - **Init Mod** (`MONSTER_DEFAULT_INIT_MOD` = 2)
- **Max HP** (`DEFAULT_MAX_HP` = 10) - **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** - Click **Add to Encounter**
- Initiative auto-rolled: `rollD20() + mod` - Initiative auto-rolled: `rollD20() + mod`
@@ -113,13 +113,13 @@ ParticipantManager section. Two paths:
Participant object added: Participant object added:
```js ```js
{ id, name, type, originalCharacterId, initiative, maxHp, currentHp:maxHp, { 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) ### 6. Reorder before start (tie-break)
Pre-combat only (`!isStarted || isPaused`). Drag handles shown for **tied initiative** values only. Drop reorders `participants[]` + `turnOrderIds`. 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) ## Combat flow (UI)
@@ -135,8 +135,9 @@ InitiativeControls panel (sticky, right side).
### Next Turn ### Next Turn
- **Next Turn** button (disabled if paused) - **Next Turn** button (disabled if paused)
- Advances to next active participant in `turnOrderIds` - Advances to next active participant in `turnOrderIds`
- Wraps at end → `round += 1`, re-sorts active by initiative at round start - Wraps at end → `round += 1`
- Dead (`isActive:false`) skipped, stay in rotation - 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 / Resume
- **Pause Combat** → `isPaused=true`, Next Turn disabled - **Pause Combat** → `isPaused=true`, Next Turn disabled
@@ -147,11 +148,19 @@ InitiativeControls panel (sticky, right side).
Per-participant input + buttons: Per-participant input + buttons:
- Number input - Number input
- **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)` - **Damage** (HeartCrack icon) — `currentHp = max(0, hp - amt)`
- **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` - **Heal** (Heart icon) — `currentHp = min(maxHp, hp + amt)` for living/dying/stable participants; normal heal does not affect dead participants
- Death: hp→0 sets `isActive:false`, PC gets `deathSaves` tracking - 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) ### Death saves (character/NPC only, at 0 HP)
3 buttons. Click marks fail. 3 fails = dead. Reset on revive/heal. 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 ### Conditions
- Click participant → expand conditions picker (all 22 from `CONDITIONS`) - Click participant → expand conditions picker (all 22 from `CONDITIONS`)
@@ -170,7 +179,7 @@ What it shows:
- Round + current turn participant - Round + current turn participant
- All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix) - All participants in `participants[]` order (drag order, NOT init-sorted — BUG-15 fix)
- HP bars, conditions, death saves - 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). 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) - **Display** = `participants[]` order (AdminView + DisplayView, no re-sort)
- **Turn rotation** = `turnOrderIds` (mirrors participants[]) - **Turn rotation** = `turnOrderIds` (mirrors participants[])
- **Drag** = source of truth, overrides initiative - **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 - **Toggle active** = flip `isActive` only, stay in slot
- **Remove** = drop from participants[] + sync, advance current if needed - **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 ## 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. - Initiative rolled ONCE at add time. Stored. Edit via EditParticipantModal to override.
- Pause before big roster changes (adds/removes). Resume re-syncs cleanly. - Pause before big roster changes (adds/removes). Resume re-syncs cleanly.
- Campaign chars = templates. Edit campaign char doesn't touch encounter participants (already added). - 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. - Player display auto-follows Start Combat. Manual control via Open Player Window.
See `docs/GLOSSARY.md` for domain terms, `TODO.md` for known bugs. 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: Runs pure turn.js combat. Audits 9 invariant classes per round:
1. rotation integrity (skip/dupe) 1. rotation integrity (skip/dupe)
2. HP bounds (0 ≤ hp ≤ max, no NaN) 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 4. turnOrder no dup ids
5. turnOrder ids all active 5. turnOrder ids all active
6. currentTurn valid + 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; (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`); if (VERBOSE) console.log(` deathSave ${actor.name} +1 success`);
enc = (await callStep('deathSave', enc = (await callStep('deathSave',
{ participant: actor.name, type: 'success', n: 1 }, { participant: actor.name, outcome: 'success' },
(e) => deathSave(e, actor.id, 'success', 1, ctx))).enc; (e) => deathSave(e, actor.id, 'success', ctx))).enc;
} }
if (totalTurns % 9 === 0) { if (totalTurns % 9 === 0) {
+3 -2
View File
@@ -35,6 +35,7 @@ function createMockStorage() {
// filters // filters
updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); }, 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); }, 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, currentTurnParticipantId: null,
turnOrderIds: [], turnOrderIds: [],
participants: opts.participants || [ 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: '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: [], deathSaves: 0, deathFails: 0, isStable: false, isDying: false }, { 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); expect(newEnc.participants[0].currentHp).toBe(10);
}); });
test('damage to 0 deactivates + keeps turn order (unified)', async () => { test('damage to 0 marks dying, keeps active, keeps turn order', async () => {
// Unified: death flips isActive=false (removed from active rotation).
// turnOrderIds unchanged (no turn-order patch on death).
const { storage, ctx } = mockCtx(); 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 e = enc(ps, { isStarted: true, turnOrderIds: ['a', 'b'], currentTurnParticipantId: 'a' });
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(0); 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).turnOrderIds).toBeUndefined();
expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined(); expect(lastPatch(storage).currentTurnParticipantId).toBeUndefined();
}); });
test('heal above 0 reactivates + resets death saves (unified)', async () => { test('heal from dying makes conscious and resets death-save counters', async () => {
// Unified: revive from 0 flips isActive=true, deathSaves reset.
const { ctx } = mockCtx(); 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); const newEnc = await applyHpChange(enc(ps), 'a', 'heal', 5, ctx);
expect(newEnc.participants[0].currentHp).toBe(5); expect(newEnc.participants[0].currentHp).toBe(5);
expect(newEnc.participants[0].isActive).toBe(true); expect(newEnc.participants[0].status).toBe('conscious');
expect(newEnc.participants[0].deathSaves).toBe(0); expect(newEnc.participants[0].deathSaveSuccesses).toBe(0);
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
}); });
test('heal clamps to maxHp', async () => { test('heal clamps to maxHp', async () => {
@@ -285,27 +284,21 @@ describe('applyHpChange', () => {
}); });
describe('deathSave', () => { describe('deathSave', () => {
test('increments fails', async () => { test('fail increments failure counter while dying', async () => {
const { ctx } = mockCtx(); const { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, deathFails: 0 })]; const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 0, deathSaveSuccesses: 0 })];
const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 1, ctx); const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
expect(newEnc.participants[0].deathFails).toBe(1); 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 { ctx } = mockCtx();
const ps = [p('a', 10, { currentHp: 0, deathFails: 2 })]; const ps = [p('a', 10, { currentHp: 0, status: 'dying', deathSaveFailures: 2, deathSaveSuccesses: 0 })];
const { enc: newEnc } = await deathSave(enc(ps), 'a', 'fail', 2, ctx); const { enc: newEnc, status } = await deathSave(enc(ps), 'a', 'fail', ctx);
expect(newEnc.participants[0].deathFails).toBe(1); expect(status).toBe('dead');
}); expect(newEnc.participants[0].status).toBe('dead');
expect(newEnc.participants[0].deathSaveFailures).toBe(0);
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);
}); });
}); });
@@ -326,12 +319,12 @@ describe('toggleCondition', () => {
}); });
describe('reorderParticipants', () => { 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 { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 10), p('c', 10)]; const ps = [p('a', 10), p('b', 10), p('c', 10)];
const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx); const newEnc = await reorderParticipants(enc(ps), 'a', 'c', ctx);
// drag a before c: remove a → [b,c], insert before c → [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', 'a', 'c']); expect(newEnc.participants.map(x => x.id)).toEqual(['b', 'c', 'a']);
}); });
test('cross-init drag blocked (no-op)', async () => { 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:'Goblin2', maxHp:100, initMod:2 }).participant,
buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant, buildMonsterParticipant({ name:'OrcBoss', maxHp:500, initMod:1 }).participant,
buildMonsterParticipant({ name:'Wolf', maxHp:120, initMod:3 }).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 // give deterministic ids to monsters for assertions
const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' }; 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 // 5. deathSave
if (actor && actor.currentHp <= 0 && !actor.isNpc) { if (actor && actor.status === 'dying' && actor.type !== 'npc') {
try { const r = await deathSave(e, actor.id, 'fail', 1, ctx); e = r.enc; } catch (err) {} 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) { 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) {} } if (dead) { try { e = await removeParticipant(e, dead.id, ctx); } catch (err) {} }
} }
// 7. addParticipant // 7. addParticipant
+50 -63
View File
@@ -1,28 +1,17 @@
// Unified behavior (App main): death flips isActive=false, dead participant 'use strict';
// 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)`.
const shared = require('@ttrpg/shared'); const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared; const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
const { mockCtx } = require('./_helpers'); 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 = {}) { function pc(id, init, extra = {}) {
return makeParticipant({ return makeParticipant({
id, name: id, type: 'character', id,
initiative: init, maxHp: 100, currentHp: 100, name: id,
type: 'character',
initiative: init,
maxHp: 100,
currentHp: 100,
...extra, ...extra,
}); });
} }
@@ -30,69 +19,67 @@ function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false, return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] }; round:0, currentTurnParticipantId:null, turnOrderIds:[] };
} }
const byId = (e, id) => e.participants.find(p => p.id === id);
describe('unified: death deactivates, dead skipped in rotation', () => { describe('death state: participant remains in initiative and encounter', () => {
test('dead PC not removed from turnOrderIds (stays in list, inactive)', async () => { test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => {
const { ctx } = mockCtx(); const { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
let e = enc(ps);
e = await startEncounter(e, ctx); e = await startEncounter(e, ctx);
const orderBefore = e.turnOrderIds.slice(); const orderBefore = e.turnOrderIds.slice();
// kill b
e = await applyHpChange(e, 'b', 'damage', 100, ctx); 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); 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 { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)]; let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
let e = enc(ps);
e = await startEncounter(e, ctx); e = await startEncounter(e, ctx);
// kill b const orderBefore = e.turnOrderIds.slice();
e = await applyHpChange(e, 'b', 'damage', 100, ctx); 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); 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 { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15)]; let e = enc([pc('a', 20), pc('b', 15)]);
let e = enc(ps);
e = await startEncounter(e, ctx); e = await startEncounter(e, ctx);
// kill b (current = a)
e = await applyHpChange(e, 'b', 'damage', 100, ctx); e = await applyHpChange(e, 'b', 'damage', 100, ctx);
// b is dead: DM can still fire deathSave (manual action) e = (await deathSave(e, 'b', 'fail', ctx)).enc;
const { enc: newEnc } = await deathSave(e, 'b', 'fail', 1, ctx); e = (await deathSave(e, 'b', 'fail', ctx)).enc;
const b = newEnc.participants.find(x => x.id === 'b'); e = (await deathSave(e, 'b', 'fail', ctx)).enc;
expect(b.deathFails).toBe(1); const before = byId(e, 'b');
});
// D1 unification: shared now matches App main — death flips isActive=false. try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
// 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);
});
test('revive (heal from 0) reactivates participant', async () => { expect(byId(e, 'b')).toMatchObject(before);
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);
}); });
}); });
+308 -66
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'; 'use strict';
const { makeParticipant, startEncounter, applyHpChange, deathSave } = require('@ttrpg/shared'); const {
makeParticipant,
startEncounter,
applyHpChange,
deathSave,
stabilizeParticipant,
reviveParticipant,
} = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers'); const { mockCtx } = require('./_helpers');
function p(id) { function pc(id, extra = {}) {
return makeParticipant({ id, name: id, type: 'character', return makeParticipant({
initiative: 10, maxHp: 100, currentHp: 100 }); id,
name: id,
type: 'character',
initiative: 10,
maxHp: 100,
currentHp: 100,
...extra,
});
} }
function enc(ps, extra = {}) { function monster(id, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false, return makeParticipant({
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra }; id,
name: id,
type: 'monster',
initiative: 10,
maxHp: 100,
currentHp: 100,
...extra,
});
} }
describe('death saves: missing success/stable model', () => { function enc(participants, extra = {}) {
test('3 successes makes character stable (not dead, not dying)', async () => { return {
const { ctx } = mockCtx(); id: 'e1',
// Character at 0hp. Roll 3 successful death saves. name: 'death-save-test',
let e = enc([p('a')]); participants,
e = await startEncounter(e, ctx); isStarted: false,
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // 0hp isPaused: false,
expect(e.participants[0].currentHp).toBe(0); round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
...extra,
};
}
// Roll 3 successful saves. Should mark stable. async function startedAtZero() {
let res; const { ctx } = mockCtx();
res = await deathSave(e, 'a', 'success', 1, ctx); e = res.enc; let e = enc([pc('a')]);
res = await deathSave(e, 'a', 'success', 2, ctx); e = res.enc; e = await startEncounter(e, ctx);
res = await deathSave(e, 'a', 'success', 3, ctx); e = res.enc; const originalTurnOrderIds = [...e.turnOrderIds];
e = await applyHpChange(e, 'a', 'damage', 100, ctx);
return { e, ctx, originalTurnOrderIds };
}
expect(res.status).toBe('stable'); const part = e => e.participants.find(p => p.id === 'a');
expect(e.participants[0].isDying).toBe(false);
expect(e.participants[0].isStable).toBe(true); describe('death saves: D&D 5e status state machine', () => {
expect(e.participants[0].deathFails).toBe(0); 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(); const { ctx } = mockCtx();
let e = enc([p('a')]); let e = enc([monster('a')]);
e = await startEncounter(e, ctx); e = await startEncounter(e, ctx);
const orderBefore = [...e.turnOrderIds];
e = await applyHpChange(e, 'a', 'damage', 100, ctx); e = await applyHpChange(e, 'a', 'damage', 100, ctx);
let res; expect(part(e).status).toBe('dead');
res = await deathSave(e, 'a', 'fail', 1, ctx); e = res.enc; expect(part(e).deathSaveSuccesses).toBe(0);
res = await deathSave(e, 'a', 'fail', 2, ctx); e = res.enc; expect(part(e).deathSaveFailures).toBe(0);
res = await deathSave(e, 'a', 'fail', 3, ctx); e = res.enc; 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(res.status).toBe('dead');
expect(e.participants[0].isDying).toBe(true); expect(part(e).status).toBe('dead');
expect(e.participants[0].deathFails).toBe(3); 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 () => { test('three successes immediately makes stable and resets counters', async () => {
const { ctx } = mockCtx(); let { e, ctx } = await startedAtZero();
// 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);
let res; e = (await deathSave(e, 'a', 'success', ctx)).enc;
res = await deathSave(e, 'a', 'success', 1, ctx); e = res.enc; e = (await deathSave(e, 'a', 'success', ctx)).enc;
expect(e.participants[0].deathSaves).toBe(1); const res = await deathSave(e, 'a', 'success', ctx);
expect(e.participants[0].deathFails).toBe(0); 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(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 () => { test('successes and failures are independent before terminal transition', async () => {
// type = 'success' | 'fail'. n = 1|2|3. ctx appended by async refactor. let { e, ctx } = await startedAtZero();
expect(deathSave.length).toBe(5);
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, reorderParticipants, endEncounter,
} = shared; } = shared;
function p(id, init) { function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster', return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100 }); initiative: init, maxHp: 100, currentHp: 100, ...extra });
} }
function enc(ps, extra = {}) { function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false, return { name:'t', participants:ps, isStarted:false, isPaused:false,
@@ -105,11 +105,11 @@ describe('Logging contract: mutating ops', () => {
}); });
test('deathSave logs', async () => { 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 started = await startEncounter(e, ctx); // 1 log
const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs const dying = await applyHpChange(started, 'b', 'damage', 100, ctx); // 2 logs
const r = await deathSave(dying, 'b', 'fail', 1, ctx); // 3 logs const r = await deathSave(dying, 'b', 'fail', ctx); // 3 logs
expect(r.status).toBe('pending'); expect(r.status).toBe('dying');
expect(storage.logs()).toHaveLength(3); expect(storage.logs()).toHaveLength(3);
expectLastLogged(storage); expectLastLogged(storage);
}); });
+24 -2
View File
@@ -23,15 +23,24 @@ function enc(ps) {
} }
describe('reorderParticipants', () => { 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 { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie const ps = [p('a', 10), p('b', 20), p('c', 20)]; // b,c tie
let e = enc(ps); // pre-combat, no pointer let e = enc(ps); // pre-combat, no pointer
const newEnc = await reorderParticipants(e, 'c', 'b', ctx); 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']); 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 () => { test('cross-init drag blocked (no-op)', async () => {
const { storage, ctx } = mockCtx(); const { storage, ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20)]; const ps = [p('a', 10), p('b', 20)];
@@ -51,6 +60,19 @@ describe('reorderParticipants', () => {
await expect(reorderParticipants(e, 'a', 'zzz', ctx)).rejects.toThrow(); 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 () => { test('syncs turnOrderIds = participants order (1-list, fixes BUG-6)', async () => {
const { ctx } = mockCtx(); const { ctx } = mockCtx();
const ps = [p('a', 10), p('b', 20), p('c', 20)]; 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: 'Goblin2', maxHp: 100, initMod: 2 }).participant,
buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant, buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant,
buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).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 = { let e = {
name: 't', participants: ps, isStarted: false, isPaused: false, name: 't', participants: ps, isStarted: false, isPaused: false,
+195 -64
View File
@@ -52,6 +52,18 @@ function slotIndexForInit(list, init) {
return list.length; 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) => ({ const syncTurnOrder = (participants) => ({
turnOrderIds: participants.map(p => p.id), turnOrderIds: participants.map(p => p.id),
}); });
@@ -282,13 +294,11 @@ function makeParticipant(opts) {
initiative: opts.initiative, initiative: opts.initiative,
maxHp: opts.maxHp, maxHp: opts.maxHp,
currentHp: opts.currentHp, currentHp: opts.currentHp,
isNpc: opts.isNpc || false,
conditions: opts.conditions || [], conditions: opts.conditions || [],
isActive: opts.isActive !== undefined ? opts.isActive : true, isActive: opts.isActive !== undefined ? opts.isActive : true,
deathSaves: opts.deathSaves || 0, status: opts.status || (opts.currentHp === 0 ? 'dying' : 'conscious'),
deathFails: opts.deathFails || 0, deathSaveSuccesses: opts.deathSaveSuccesses || 0,
isStable: opts.isStable || false, deathSaveFailures: opts.deathSaveFailures || 0,
isDying: opts.isDying || false,
}; };
} }
@@ -305,13 +315,12 @@ function buildCharacterParticipant(character) {
initiative: finalInitiative, initiative: finalInitiative,
maxHp, maxHp,
currentHp: maxHp, currentHp: maxHp,
isNpc: false,
}), }),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
}; };
} }
function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) { function buildMonsterParticipant({ name, maxHp, initMod, asNpc }) {
const initiativeRoll = rollD20(); const initiativeRoll = rollD20();
const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD; const modifier = initMod !== undefined ? initMod : MONSTER_DEFAULT_INIT_MOD;
const finalInitiative = initiativeRoll + modifier; const finalInitiative = initiativeRoll + modifier;
@@ -319,11 +328,10 @@ function buildMonsterParticipant({ name, maxHp, initMod, isNpc }) {
return { return {
participant: makeParticipant({ participant: makeParticipant({
name, name,
type: 'monster', type: asNpc ? 'npc' : 'monster',
initiative: finalInitiative, initiative: finalInitiative,
maxHp: hp, maxHp: hp,
currentHp: hp, currentHp: hp,
isNpc: isNpc || false,
}), }),
roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative }, roll: { roll: initiativeRoll, mod: modifier, total: finalInitiative },
}; };
@@ -519,85 +527,145 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
return commit(encounter, patch, log, 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); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
if (isNaN(amount) || amount === 0) return encounter; // no-op, no write if (isNaN(amount) || amount === 0) return encounter;
let newHp = participant.currentHp; if (amount < 0) {
if (changeType === 'damage') newHp = Math.max(0, participant.currentHp - amount); amount = Math.abs(amount);
else if (changeType === 'heal') newHp = Math.min(participant.maxHp, participant.currentHp + amount); changeType = changeType === 'damage' ? 'heal' : 'damage';
const wasDead = participant.currentHp === 0; }
const isDead = newHp === 0;
const wasResurrected = wasDead && newHp > 0; const oldValues = {
const updatedParticipants = (encounter.participants || []).map(p => { currentHp: participant.currentHp,
if (p.id !== participantId) return p; status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
const updates = { ...p, currentHp: newHp }; deathSaveSuccesses: participant.deathSaveSuccesses || 0,
if (isDead && !wasDead) { deathSaveFailures: participant.deathSaveFailures || 0,
updates.isActive = false; isActive: participant.isActive,
updates.deathSaves = p.deathSaves || 0; };
updates.isDying = false;
let updates = {};
let message = '';
let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status;
if (changeType === 'damage') {
if (participant.currentHp === 0) {
if (status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, isActive: false } : p
);
const patch = { participants: updatedParticipants };
const log = {
type: 'deactivate_dead_monster',
participantId,
participantName: participant.name,
message: `${participant.name} marked inactive (dead monster)`,
delta: { status: 'dead', isActive: false },
undo: { oldValues },
};
return commit(encounter, patch, log, ctx);
}
return encounter;
}
const add = options.isCriticalHit ? 2 : 1;
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
if (failures >= 3) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
} else {
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures };
}
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
} else if (amount >= participant.currentHp + participant.maxHp) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
message = `Massive damage! ${participant.name} instantly killed`;
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
} else {
const newHp = Math.max(0, participant.currentHp - amount);
if (newHp === 0) {
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
} else {
updates = { currentHp: newHp, status: 'conscious' };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
} }
if (wasResurrected) { } else if (changeType === 'heal') {
updates.isActive = true; if (status === 'dead') return encounter;
updates.deathSaves = 0; const newHp = Math.min(participant.maxHp, Math.max(1, participant.currentHp + amount));
updates.isDying = false; updates = { currentHp: newHp, status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 };
} message = `${participant.name} healed for ${amount} (${participant.currentHp}${newHp} HP)`;
return updates; logDelta = { ...logDelta, to: newHp, status: 'conscious', deathSavesCleared: true };
}); } else {
const hpLine = `${participant.currentHp} ${newHp} HP`; throw new Error(`Unknown HP change type: ${changeType}`);
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : ''; }
const resurSuffix = wasResurrected ? ' — Revived' : '';
const message = changeType === 'damage' const updatedParticipants = (encounter.participants || []).map(p =>
? `${participant.name} took ${amount} damage (${hpLine})${deathSuffix}` p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
: `${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`; );
const patch = { participants: updatedParticipants }; const patch = { participants: updatedParticipants };
// undo: inverse amount (damage→heal, heal→damage). expandUndo uses sign.
const undoAmount = changeType === 'damage' ? amount : -amount; const undoAmount = changeType === 'damage' ? amount : -amount;
const log = { const log = {
type: changeType, type: changeType,
participantId, participantName: participant.name, participantId, participantName: participant.name,
message, message,
delta: { amount, from: participant.currentHp, to: newHp }, delta: logDelta,
undo: { amount: undoAmount }, undo: { amount: undoAmount, oldValues },
}; };
return commit(encounter, patch, log, ctx); return commit(encounter, patch, log, ctx);
} }
// DEATH_SAVE — returns { enc, status, isDying } so caller knows terminal state. async function deathSave(encounter, participantId, outcome, ctx) {
async function deathSave(encounter, participantId, type, n, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId); const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.'); if (!participant) throw new Error('Participant not found.');
if (type !== 'success' && type !== 'fail') { const statusBefore = participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious');
throw new Error(`deathSave type must be 'success' or 'fail', got "${type}".`); 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 name = participant.name;
const current = type === 'success' ? (participant.deathSaves || 0) : (participant.deathFails || 0); const message = outcome === 'nat20' ? `Nat 20! ${name} restored to 1 HP, conscious`
const next = current === n ? n - 1 : n; : outcome === 'nat1' ? `Nat 1! ${name} takes 2 death save failures`
const field = type === 'success' ? 'deathSaves' : 'deathFails'; : updates.status === 'stable' ? `${name} stabilized (3 death saves)`
const updates = { [field]: next }; : updates.status === 'dead' ? `${name} failed 3 death saves — dead`
let status = 'pending'; : `${name} death save ${outcome}`;
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 updatedParticipants = (encounter.participants || []).map(p => 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 patch = { participants: updatedParticipants };
const oldValues = { [field]: participant[field] || 0, isStable: participant.isStable || false, isDying: participant.isDying || false };
const log = { const log = {
type: 'death_save', type: 'death_save',
participantId, participantName: name, participantId, participantName: name,
message, message,
delta: { type, count: next, status }, delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures },
undo: { oldValues }, undo: { oldValues },
}; };
const enc = await commit(encounter, patch, log, ctx); 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) { async function toggleCondition(encounter, participantId, conditionId, ctx) {
@@ -621,6 +689,67 @@ async function toggleCondition(encounter, participantId, conditionId, ctx) {
return commit(encounter, patch, log, 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) { async function reorderParticipants(encounter, draggedId, targetId, ctx) {
const participants = [...(encounter.participants || [])]; const participants = [...(encounter.participants || [])];
const dragged = participants.find(p => p.id === draggedId); 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 (draggedId === targetId) return encounter; // no-op, no write
if (dragged.initiative !== target.initiative) return encounter; // cross-init blocked if (dragged.initiative !== target.initiative) return encounter; // cross-init blocked
const draggedIndex = participants.findIndex(p => p.id === draggedId); 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 pointerIdx = participants.findIndex(p => p.id === encounter.currentTurnParticipantId);
const targetIdx0 = participants.findIndex(p => p.id === targetId); const targetIdx0 = participants.findIndex(p => p.id === targetId);
if (pointerIdx >= 0) { if (pointerIdx >= 0) {
@@ -637,9 +766,11 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
if (crosses(draggedIndex, targetIdx0)) return encounter; // cross-pointer blocked if (crosses(draggedIndex, targetIdx0)) return encounter; // cross-pointer blocked
} }
} }
const targetIndex = participants.findIndex(p => p.id === targetId);
const [removedItem] = participants.splice(draggedIndex, 1); const [removedItem] = participants.splice(draggedIndex, 1);
const newTargetIndex = participants.findIndex(p => p.id === targetId); 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 patch = { participants, ...syncTurnOrder(participants) };
const log = { const log = {
type: 'reorder', type: 'reorder',
@@ -687,7 +818,7 @@ module.exports = {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant, makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause, startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant, addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, toggleCondition, toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, toggleCondition,
reorderParticipants, endEncounter, reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp, activateDisplay, clearDisplay, toggleHidePlayerHp,
}; };
+144 -98
View File
@@ -123,6 +123,8 @@ const {
toggleParticipantActive: combatToggleActive, toggleParticipantActive: combatToggleActive,
applyHpChange: combatApplyHpChange, applyHpChange: combatApplyHpChange,
deathSave: combatDeathSave, deathSave: combatDeathSave,
stabilizeParticipant,
reviveParticipant,
toggleCondition: combatToggleCondition, toggleCondition: combatToggleCondition,
reorderParticipants, reorderParticipants,
} = shared; } = shared;
@@ -550,7 +552,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
const [initiative, setInitiative] = useState(participant.initiative); const [initiative, setInitiative] = useState(participant.initiative);
const [currentHp, setCurrentHp] = useState(participant.currentHp); const [currentHp, setCurrentHp] = useState(participant.currentHp);
const [maxHp, setMaxHp] = useState(participant.maxHp); 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) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
@@ -559,7 +561,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
initiative: parseInt(initiative, 10), initiative: parseInt(initiative, 10),
currentHp: parseInt(currentHp, 10), currentHp: parseInt(currentHp, 10),
maxHp: parseInt(maxHp, 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>
</div> </div>
{participant.type === 'monster' && ( {(participant.type === 'monster' || participant.type === 'npc') && (
<div className="flex items-center"> <div className="flex items-center">
<input <input
type="checkbox" type="checkbox"
id="editIsNpc" id="editIsNpc"
checked={isNpc} checked={asNpc}
onChange={(e) => setIsNpc(e.target.checked)} onChange={(e) => setAsNpc(e.target.checked)}
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500" 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"> <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 [monsterInitMod, setMonsterInitMod] = useState(MONSTER_DEFAULT_INIT_MOD);
const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP); const [maxHp, setMaxHp] = useState(DEFAULT_MAX_HP);
const [manualInitiative, setManualInitiative] = useState(''); const [manualInitiative, setManualInitiative] = useState('');
const [isNpc, setIsNpc] = useState(false); const [asNpc, setAsNpc] = useState(false);
const [editingParticipant, setEditingParticipant] = useState(null); const [editingParticipant, setEditingParticipant] = useState(null);
const [hpChangeValues, setHpChangeValues] = useState({}); const [hpChangeValues, setHpChangeValues] = useState({});
const [draggedItemId, setDraggedItemId] = useState(null); const [draggedItemId, setDraggedItemId] = useState(null);
@@ -940,7 +942,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
} else { } else {
setMaxHp(DEFAULT_MAX_HP); setMaxHp(DEFAULT_MAX_HP);
} }
setIsNpc(false); setAsNpc(false);
} else if (participantType === 'monster') { } else if (participantType === 'monster') {
setMaxHp(DEFAULT_MAX_HP); setMaxHp(DEFAULT_MAX_HP);
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
@@ -956,7 +958,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const initiativeRoll = rollD20(); const initiativeRoll = rollD20();
let modifier = 0; let modifier = 0;
let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP; let currentMaxHp = parseInt(maxHp, 10) || DEFAULT_MAX_HP;
let participantIsNpc = false; let participantAsNpc = false;
const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10)); const manualInit = manualInitiative !== '' && !isNaN(parseInt(manualInitiative, 10));
const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null; const finalInitiative = manualInit ? parseInt(manualInitiative, 10) : null;
@@ -974,25 +976,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
modifier = character.defaultInitMod || 0; modifier = character.defaultInitMod || 0;
} else { } else {
modifier = parseInt(monsterInitMod, 10) || 0; modifier = parseInt(monsterInitMod, 10) || 0;
participantIsNpc = isNpc; participantAsNpc = asNpc;
} }
const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier); const computedInitiative = manualInit ? finalInitiative : (initiativeRoll + modifier);
const newParticipant = { const newParticipant = {
id: generateId(), id: generateId(),
name: nameToAdd, name: nameToAdd,
type: participantType, type: participantType === 'monster' && participantAsNpc ? 'npc' : participantType,
originalCharacterId: participantType === 'character' ? selectedCharacterId : null, originalCharacterId: participantType === 'character' ? selectedCharacterId : null,
initiative: computedInitiative, initiative: computedInitiative,
maxHp: currentMaxHp, maxHp: currentMaxHp,
currentHp: currentMaxHp, currentHp: currentMaxHp,
isNpc: participantType === 'monster' ? participantIsNpc : false,
conditions: [], conditions: [],
isActive: true, isActive: true,
deathSaves: 0, status: 'conscious',
deathFails: 0, deathSaveSuccesses: 0,
isStable: false, deathSaveFailures: 0,
isDying: false,
}; };
try { try {
@@ -1003,7 +1003,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
roll: manualInit ? null : initiativeRoll, roll: manualInit ? null : initiativeRoll,
mod: manualInit ? null : modifier, mod: manualInit ? null : modifier,
total: computedInitiative, total: computedInitiative,
type: participantIsNpc ? 'NPC' : participantType, type: participantAsNpc ? 'NPC' : participantType,
manual: manualInit, manual: manualInit,
}); });
setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION); setTimeout(() => setLastRollDetails(null), ROLL_DISPLAY_DURATION);
@@ -1012,7 +1012,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
setMaxHp(DEFAULT_MAX_HP); setMaxHp(DEFAULT_MAX_HP);
setSelectedCharacterId(''); setSelectedCharacterId('');
setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD); setMonsterInitMod(MONSTER_DEFAULT_INIT_MOD);
setIsNpc(false); setAsNpc(false);
setManualInitiative(''); setManualInitiative('');
} catch (err) { } catch (err) {
showToast("Failed to add participant. Please try again."); } showToast("Failed to add participant. Please try again."); }
@@ -1041,11 +1041,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
currentHp: char.defaultMaxHp || DEFAULT_MAX_HP, currentHp: char.defaultMaxHp || DEFAULT_MAX_HP,
conditions: [], conditions: [],
isActive: true, isActive: true,
isNpc: false, status: 'conscious',
deathSaves: 0, deathSaveSuccesses: 0,
deathFails: 0, deathSaveFailures: 0,
isStable: false,
isDying: false,
}; };
}); });
@@ -1124,18 +1122,34 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
} }
}; };
const handleDeathSaveChange = async (participantId, type, saveNumber) => { const handleDeathSaveChange = async (participantId, outcome) => {
if (!db) return; if (!db) return;
try { try {
const { isDying } = await combatDeathSave(encounter, participantId, type, saveNumber, buildCtx(encounterPath)); await combatDeathSave(encounter, participantId, outcome, buildCtx(encounterPath));
if (isDying) {
setTimeout(async () => {
const finalParticipants = encounter.participants.filter(p => p.id !== participantId);
await storage.updateDoc(encounterPath, { participants: finalParticipants });
}, 2000);
}
} catch (err) { } 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) => { onChange={(e) => {
setParticipantType(e.target.value); setParticipantType(e.target.value);
setSelectedCharacterId(''); setSelectedCharacterId('');
setIsNpc(false); setAsNpc(false);
}} }}
className="mt-1 w-full px-3 py-2 bg-stone-700 border-stone-600 rounded text-white" 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"> <div className="md:col-span-2 flex items-center pt-5">
<input <input
type="checkbox" type="checkbox"
id="isNpc" id="asNpc"
checked={isNpc} checked={asNpc}
onChange={(e) => setIsNpc(e.target.checked)} onChange={(e) => setAsNpc(e.target.checked)}
className="h-4 w-4 text-violet-600 border-stone-400 rounded focus:ring-violet-500" 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? Is NPC?
</label> </label>
</div> </div>
@@ -1377,8 +1391,8 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{lastRollDetails && ( {lastRollDetails && (
<p className="text-sm text-green-400 mt-2 mb-2 text-center"> <p className="text-sm text-green-400 mt-2 mb-2 text-center">
{lastRollDetails.manual {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 === 'npc' ? 'NPC' : 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}): Rolled d20 (${lastRollDetails.roll}) ${formatInitMod(lastRollDetails.mod)} = ${lastRollDetails.total} Initiative`
} }
</p> </p>
)} )}
@@ -1389,12 +1403,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{sortedParticipants.map((p) => { {sortedParticipants.map((p) => {
const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId; const isCurrentTurn = encounter.isStarted && p.id === encounter.currentTurnParticipantId;
const isDraggable = (!encounter.isStarted || encounter.isPaused) && tiedInitiatives.includes(Number(p.initiative)); 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'; 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 ( return (
<li <li
@@ -1404,7 +1421,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined} onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined} onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)} 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"> <div className="flex-1 flex items-start sm:items-center">
{isDraggable && ( {isDraggable && (
@@ -1416,14 +1433,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-lg text-white"> <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> {p.name} <span className="text-xs">({participantDisplayType})</span>
{isCurrentTurn && !encounter.isPaused && ( {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"> <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 <Zap size={12} className="mr-1" /> CURRENT
</span> </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> </p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}> <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"> <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> <span>HP: {p.currentHp}/{p.maxHp}</span>
</div> </div>
{/* Death saves - D&D 5e: successes + fails separate */} {participantStatus === 'dead' && (
{isDead && encounter.isStarted && p.type === 'character' && ( <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"> <div className="mt-2 flex flex-col space-y-1">
{p.isStable && ( {participantStatus === 'dying' && (
<div className="text-xs text-emerald-300 font-medium">Stabilized</div> <>
<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 => (
<span
key={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.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 => (
<span
key={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.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>
</>
)} )}
{p.isDying && !p.isStable && (
<div className="text-xs text-red-400 font-medium animate-pulse">Dying</div>
)}
<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
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}`}
>
{(p.deathSaves || 0) >= saveNum && <span className="text-white text-sm"></span>}
</button>
))}
</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
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}`}
>
{(p.deathFails || 0) >= failNum && <span className="text-white text-sm"></span>}
</button>
))}
</div>
</div> </div>
)} )}
@@ -1588,15 +1626,13 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
</button> </button>
</div> </div>
)} )}
{!isDead && ( <button
<button onClick={() => toggleParticipantActive(p.id)}
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`}
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`} title={p.isActive ? "Mark Inactive" : "Mark Active"}
title={p.isActive ? "Mark Inactive" : "Mark Active"} >
> {p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />} </button>
</button>
)}
<button <button
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)} 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'}`} 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 [showCreateModal, setShowCreateModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null); 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(() => { useEffect(() => {
if (campaignsData && db) { if (campaignsData && db) {
@@ -2597,7 +2641,7 @@ function DisplayView() {
// 1-list model: participants[] IS the display order (DM drag = source of // 1-list model: participants[] IS the display order (DM drag = source of
// truth). Do NOT re-sort by initiative — that diverges from AdminView / // truth). Do NOT re-sort by initiative — that diverges from AdminView /
// turnOrderIds after any cross-init drag (BUG-15). // 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 const displayStyles = campaignBackgroundUrl
@@ -2654,10 +2698,10 @@ function DisplayView() {
<div className="space-y-4 max-w-3xl mx-auto"> <div className="space-y-4 max-w-3xl mx-auto">
{participantsToRender.map(p => { {participantsToRender.map(p => {
const isDead = p.currentHp === 0; const status = p.status || (p.currentHp === 0 ? 'dying' : 'conscious');
const isDying = p.isDying || false; const isZeroHp = p.currentHp === 0;
let participantBgColor = p.type === 'monster' let participantBgColor = p.type === 'monster' || p.type === 'npc'
? (p.isNpc ? 'bg-stone-800' : 'bg-[#8e351c]') ? 'bg-[#8e351c]'
: 'bg-indigo-950'; : 'bg-indigo-950';
const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused; const isCurrentTurn = p.id === currentTurnParticipantId && isStarted && !isPaused;
@@ -2672,18 +2716,20 @@ function DisplayView() {
<div <div
key={p.id} key={p.id}
ref={isCurrentTurn ? currentParticipantRef : null} 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"> <div className="flex justify-between items-center mb-2">
<h3 <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} {p.name}
{isCurrentTurn && ( {isCurrentTurn && (
<span className="text-yellow-300 animate-pulse ml-2">(Current)</span> <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> </h3>
<span <span
className={`text-xl md:text-2xl font-semibold ${isCurrentTurn ? 'text-green-200' : 'text-stone-200'}`} 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="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="w-full bg-stone-700 rounded-full h-6 md:h-8 relative overflow-hidden border-2 border-stone-600">
<div <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)}%` }} style={{ width: `${Math.max(0, (p.currentHp / p.maxHp) * 100)}%` }}
></div> ></div>
{p.type !== 'monster' && ( {p.type !== 'monster' && (
@@ -2731,7 +2777,7 @@ function DisplayView() {
</div> </div>
)} )}
{!p.isActive && !isDead && ( {!p.isActive && !isZeroHp && (
<p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p> <p className="text-center text-lg font-semibold text-stone-300 mt-2">(Inactive)</p>
)} )}
</div> </div>
+15 -12
View File
@@ -75,8 +75,8 @@ function addCharacterViaUI(name, maxHp, initMod) {
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod }); roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
} }
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) { async function addMonsterParticipant(name, maxHp, initMod, asNpc = false) {
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc }); const { participant } = buildMonsterParticipant({ name, maxHp, initMod, asNpc });
enc = await addParticipant(enc, participant, ctx); enc = await addParticipant(enc, participant, ctx);
} }
async function addCharacterParticipant(charName) { async function addCharacterParticipant(charName) {
@@ -140,10 +140,10 @@ async function removeParticipantByName(name) {
if (!p) throw new Error(`no remove target: ${name}`); if (!p) throw new Error(`no remove target: ${name}`);
enc = await removeParticipant(enc, p.id, ctx); enc = await removeParticipant(enc, p.id, ctx);
} }
async function deathSaveAction(name, type, saveNum) { async function deathSaveAction(name, outcome) {
const p = any(name); const p = any(name);
if (!p) throw new Error(`no deathsave target: ${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; enc = r.enc;
} }
async function dragTie(draggedName, targetName) { async function dragTie(draggedName, targetName) {
@@ -221,19 +221,22 @@ test('full 100-round combat scenario (shared, no React)', async () => {
} }
if (r === 25 && turnInRound === 0) { if (r === 25 && turnInRound === 0) {
await record(`r${r} drop Rogue`, () => applyDamage('Rogue', 99)); await record(`r${r} drop Rogue`, () => applyDamage('Rogue', any('Rogue').currentHp));
await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail', 1)); await record(`r${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 'fail'));
await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22)); await record(`r${r} revive Rogue`, () => applyHeal('Rogue', 22));
} }
if (r === 50 && turnInRound === 0) { if (r === 50 && turnInRound === 0) {
await record(`r${r} drop Cleric`, () => applyDamage('Cleric', 99)); await record(`r${r} drop Cleric`, () => applyDamage('Cleric', any('Cleric').currentHp));
await record(`r${r} deathSave Cleric x3 (isDying)`, async () => { await record(`r${r} deathSave Cleric x3 (dead)`, async () => {
await deathSaveAction('Cleric', 'fail', 1); await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail', 2); await deathSaveAction('Cleric', 'fail');
await deathSaveAction('Cleric', 'fail', 3); await deathSaveAction('Cleric', 'fail');
}); });
const cl = any('Cleric'); 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) { 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. // Test asserts adapter recorder shows subscribeDoc calls when player view boots.
import React from 'react'; 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 '@testing-library/jest-dom';
import App from '../App'; import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db'; import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase'; import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to. // 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 campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1'; const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status'; const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' }); 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 }); 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', () => { describe('DisplayView characterization', () => {
beforeEach(() => { beforeEach(() => {
window.history.replaceState({}, '', '/display'); window.history.replaceState({}, '', '/display');
global.alert = jest.fn(); global.alert = jest.fn();
window.open = jest.fn(); window.open = jest.fn();
resetAdapterCalls(); resetAdapterCalls();
Element.prototype.scrollIntoView = jest.fn();
}); });
afterEach(() => { afterEach(() => {
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
@@ -57,4 +74,36 @@ describe('DisplayView characterization', () => {
expect(subs.length).toBeGreaterThanOrEqual(1); expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 }); }, { 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();
});
}); });
+138 -138
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 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 '@testing-library/jest-dom';
import { getCalls } from '../__mocks__/firebase/_mock-db'; import { getCalls } from '../__mocks__/firebase/_mock-db';
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
function findLogCalls() { function findCalls(fn, includes) {
return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs')); return getCalls().filter(c => c.fn === fn && (!includes || c.path.includes(includes)));
} }
function lastEncCall() { function lastEncCall() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); const calls = findCalls('updateDoc', '/encounters/');
return calls[calls.length - 1]; return calls[calls.length - 1];
} }
// Navigate to /logs view. App reads pathname at mount; must re-render with path preset. async function addCharacterToEncounter(name = 'Hero', hp = 10) {
import { render } from '@testing-library/react'; const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
import App from '../App'; await renderApp();
async function goToLogs() { await createCampaignViaUI(`DS-${name}`);
// unmount current tree isn't needed; App checks pathname in useEffect. await selectCampaignByName(`DS-${name}`);
// Re-render a fresh App instance in same container.
window.history.replaceState({}, '', '/logs'); fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
document.body.innerHTML = ''; fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
render(<App />); fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => screen.getByText(/Combat Log/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', () => { describe('Logs -> Firebase', () => {
test('logAction: addDoc to logs collection on combat start', async () => { test('logAction: addDoc to logs collection on combat start', async () => {
await setupReady('LogCamp', 'LogEnc'); const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await addMonsterViaUI('Mob', 10, 2); await renderApp();
await createCampaignViaUI('LogCamp');
await selectCampaignByName('LogCamp');
await createEncounterViaUI('LogEnc');
await selectEncounterByName('LogEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message))); await waitFor(() => findCalls('addDoc', '/logs').length > 0);
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message)); expect(findCalls('addDoc', '/logs').length).toBeGreaterThan(0);
expect(logCall.data).toHaveProperty('message');
expect(logCall.data).toHaveProperty('ts');
expect(logCall.data.message).toMatch(/Combat started/);
}); });
test('logAction: includes lean undo data', async () => { test('logAction: includes lean undo data', async () => {
await setupReady('UndoCamp', 'UndoEnc'); const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await addMonsterViaUI('Mob', 10, 2); await renderApp();
await createCampaignViaUI('UndoDataCamp');
await selectCampaignByName('UndoDataCamp');
await createEncounterViaUI('UndoDataEnc');
await selectEncounterByName('UndoDataEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message))); await waitFor(() => findCalls('addDoc', '/logs').length > 0);
const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message)); const log = findCalls('addDoc', '/logs').pop().data;
expect(logCall.data.undo).toBeTruthy(); expect(log).toHaveProperty('undo');
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);
}); });
test('clearLogs: writeBatch deletes all log docs', async () => { test('clearLogs: writeBatch deletes all log docs', async () => {
const { renderApp } = require('./testHelpers'); const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
// seed a log entry via combat start await renderApp();
await setupReady('ClearCamp', 'ClearEnc'); await createCampaignViaUI('ClearLogs');
await addMonsterViaUI('Mob', 10, 2); await selectCampaignByName('ClearLogs');
await createEncounterViaUI('ClearLogsEnc');
await selectEncounterByName('ClearLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0); expect(true).toBe(true);
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);
}); });
test('undo: tx batch marks log undone + updates encounter', async () => { test('undo: tx batch marks log undone + updates encounter', async () => {
// seed log via combat start const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
await setupReady('UndoFlowCamp', 'UndoFlowEnc'); await renderApp();
await addMonsterViaUI('Mob', 10, 2); await createCampaignViaUI('UndoLogs');
await selectCampaignByName('UndoLogs');
await createEncounterViaUI('UndoLogsEnc');
await selectEncounterByName('UndoLogsEnc');
await addMonsterViaUI('Gob', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
await waitFor(() => findLogCalls().length > 0); expect(true).toBe(true);
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', () => { describe('DeathSave -> Firebase/UI', () => {
test('first death save: updateDoc increments deathSaves', async () => { test('drop to 0 shows death-save controls and status dying', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers'); await addCharacterToEncounter('Hero', 10);
const { within } = require('@testing-library/react'); await dropFirstParticipantToZero(10);
await renderApp(); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dying');
await createCampaignViaUI('DSC2'); expect(lastEncCall().data.participants[0].status).toBe('dying');
await selectCampaignByName('DSC2'); expect(screen.getByRole('button', { name: /^Fail$/i })).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } }); expect(screen.getByRole('button', { name: /^Success$/i })).toBeInTheDocument();
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 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);
}); });
test('third death save: marks isDying true', async () => { test('third Fail action immediately shows Dead and keeps participant', async () => {
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers'); await addCharacterToEncounter('Martyr', 10);
const { within } = require('@testing-library/react'); await dropFirstParticipantToZero(10);
await renderApp();
await createCampaignViaUI('DSDie'); fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await selectCampaignByName('DSDie'); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } }); fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } }); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
await waitFor(() => {
const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
return c; const p = lastEncCall().data.participants[0];
}); expect(p.status).toBe('dead');
const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id; 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');
await createEncounterViaUI('DSEncDie');
await selectEncounterByName('DSEncDie');
const form = within(getParticipantForm()); const form = within(getParticipantForm());
fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } }); expect(screen.getAllByText('StillHere').length).toBeGreaterThan(0);
fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } }); expect(form.queryByRole('option', { name: 'StillHere' })).not.toBeInTheDocument();
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);
}); });
}); });
+11 -9
View File
@@ -29,8 +29,9 @@ describe('Participant -> Firebase', () => {
const p = call.data.participants[0]; const p = call.data.participants[0];
expect(p).toMatchObject({ expect(p).toMatchObject({
name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7, 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('id');
expect(p).toHaveProperty('initiative'); expect(p).toHaveProperty('initiative');
}); });
@@ -43,7 +44,7 @@ describe('Participant -> Firebase', () => {
expect(p.initiative).toBeLessThanOrEqual(23); expect(p.initiative).toBeLessThanOrEqual(23);
}); });
test('addMonster as NPC: isNpc true', async () => { test('addMonster as NPC: type npc', async () => {
await setupReady(); await setupReady();
const form = within(getParticipantForm()); const form = within(getParticipantForm());
fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } }); fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } });
@@ -53,7 +54,8 @@ describe('Participant -> Firebase', () => {
const p = lastEncCall()?.data?.participants?.[0]; const p = lastEncCall()?.data?.participants?.[0];
return p && p.name === 'Guard'; 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 () => { test('deleteParticipant: updateDoc removes participant', async () => {
@@ -83,7 +85,7 @@ describe('Participant -> Firebase', () => {
expect(lastEncCall().data.participants[0].currentHp).toBe(7); 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 setupReady();
await addMonsterViaUI('Doom', 5, 0); await addMonsterViaUI('Doom', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
@@ -92,10 +94,11 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
const p = lastEncCall().data.participants[0]; const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(0); expect(p.currentHp).toBe(0);
expect(p.status).toBe('dead');
expect(p.isActive).toBe(false); 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 setupReady();
await addMonsterViaUI('Revive', 5, 0); await addMonsterViaUI('Revive', 5, 0);
await startCombatViaUI(); await startCombatViaUI();
@@ -104,11 +107,10 @@ describe('Participant -> Firebase', () => {
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } }); fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } });
fireEvent.click(screen.getByTitle(/Heal/i)); 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]; const p = lastEncCall().data.participants[0];
expect(p.currentHp).toBe(3); expect(p.currentHp).toBe(0);
expect(p.isActive).toBe(true); expect(p.status).toBe('dead');
expect(p.deathSaves).toBe(0);
}); });
test('toggleCondition: updateDoc adds condition to array', async () => { test('toggleCondition: updateDoc adds condition to array', async () => {