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
+50 -63
View File
@@ -1,28 +1,17 @@
// Unified behavior (App main): death flips isActive=false, dead participant
// removed from active rotation, skipped by nextTurn. deathSave is a manual
// DM action (button), not tied to rotation. turnOrderIds unchanged on death
// (only isActive flag flips). Revive (heal from 0) reactivates.
//
// Previous "M4: dead stays in rotation" concept reversed by unification.
//
// New API: mutating funcs are async, take ctx last, write encounter + log
// internally, return newEnc. Chained calls become `e = await fn(e, ctx)`.
'use strict';
const shared = require('@ttrpg/shared');
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
const { mockCtx } = require('./_helpers');
function p(id, init, extra = {}) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100,
...extra,
});
}
function pc(id, init, extra = {}) {
return makeParticipant({
id, name: id, type: 'character',
initiative: init, maxHp: 100, currentHp: 100,
id,
name: id,
type: 'character',
initiative: init,
maxHp: 100,
currentHp: 100,
...extra,
});
}
@@ -30,69 +19,67 @@ function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const byId = (e, id) => e.participants.find(p => p.id === id);
describe('unified: death deactivates, dead skipped in rotation', () => {
test('dead PC not removed from turnOrderIds (stays in list, inactive)', async () => {
describe('death state: participant remains in initiative and encounter', () => {
test('dropping to 0 keeps PC active, in participants, and in turnOrderIds', async () => {
const { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps);
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = await startEncounter(e, ctx);
const orderBefore = e.turnOrderIds.slice();
// kill b
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
expect(byId(e, 'b').status).toBe('dying');
expect(byId(e, 'b').isActive).not.toBe(false);
expect(e.participants.map(p => p.id)).toContain('b');
expect(e.turnOrderIds).toEqual(orderBefore);
});
test('dead PC skipped by nextTurn (isActive=false)', async () => {
test('dead PC remains in participants and turnOrderIds after three failures', async () => {
const { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
let e = enc(ps);
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = await startEncounter(e, ctx);
// kill b
const orderBefore = e.turnOrderIds.slice();
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
// advance: a→c (b skipped, inactive)
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
expect(byId(e, 'b').status).toBe('dead');
expect(byId(e, 'b').isActive).not.toBe(false);
expect(e.participants.map(p => p.id)).toContain('b');
expect(e.turnOrderIds).toEqual(orderBefore);
});
test('nextTurn may still land on dead PC because DM/tool events may matter', async () => {
const { ctx } = mockCtx();
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = await nextTurn(e, ctx);
expect(e.currentTurnParticipantId).toBe('c');
expect(e.currentTurnParticipantId).toBe('b');
expect(byId(e, 'b').status).toBe('dead');
});
test('dead PC deathSave fires on manual call (not via rotation)', async () => {
test('deathSave rejected/no-op once dead', async () => {
const { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps);
let e = enc([pc('a', 20), pc('b', 15)]);
e = await startEncounter(e, ctx);
// kill b (current = a)
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
// b is dead: DM can still fire deathSave (manual action)
const { enc: newEnc } = await deathSave(e, 'b', 'fail', 1, ctx);
const b = newEnc.participants.find(x => x.id === 'b');
expect(b.deathFails).toBe(1);
});
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
e = (await deathSave(e, 'b', 'fail', ctx)).enc;
const before = byId(e, 'b');
// D1 unification: shared now matches App main — death flips isActive=false.
// Dead participant removed from active rotation (skipped by nextTurn).
test('dead PC auto-set isActive=false by applyHpChange', async () => {
const { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
const b = e.participants.find(x => x.id === 'b');
expect(b.isActive).toBe(false);
});
try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
test('revive (heal from 0) reactivates participant', async () => {
const { ctx } = mockCtx();
const ps = [pc('a', 20), pc('b', 15)];
let e = enc(ps);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'b', 'damage', 100, ctx);
const dead = e.participants.find(x => x.id === 'b');
expect(dead.isActive).toBe(false);
// heal b back up
e = await applyHpChange(e, 'b', 'heal', 50, ctx);
const revived = e.participants.find(x => x.id === 'b');
expect(revived.isActive).toBe(true);
expect(revived.currentHp).toBe(50);
expect(revived.deathSaves).toBe(0);
expect(byId(e, 'b')).toMatchObject(before);
});
});