Old model broken: single deathSaves counter, 3=revive. No fails tracked,
successes conflated with fails. DM couldn't model real death save outcomes.
New model (D&D 5e):
- deathSave(enc, id, type, n) — type='success'|'fail', n=1|2|3
- Participant fields: deathSaves (successes), deathFails, isStable, isDying
- 3 successes = stable (0hp unconscious, safe until healed)
- 3 failures = dead (isDying, caller animates removal)
- Toggling same pip = undo that pip
- Returns { patch, log, status } — status: 'stable'|'dead'|'pending'
isDying back-compat flag kept for App.js removal animation
App.js:
- UI split into green ✓ saves row + red ✕ fails row
- Status badges: Stabilized (emerald) / Dying (red pulse)
- handleDeathSaveChange(participantId, type, saveNumber) — new sig
- makeParticipant init: deathFails + isStable defaults
Data loss: old single-counter participants lose saves (feature didn't work,
no real state to preserve). User confirmed acceptable.
Tests:
- turn.deathsave.test.js: RED-then-GREEN, 3-success stable, 3-fail dead,
independent tracking, new signature
- Updated 6 callers across characterization/combat/dead-skip/logging/scenario
- Logs UI tests: new title selectors (Success N / Fail N)
243 tests green.
91 lines
3.4 KiB
JavaScript
91 lines
3.4 KiB
JavaScript
// 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.
|
|
|
|
const shared = require('@ttrpg/shared');
|
|
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
|
|
|
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,
|
|
...extra,
|
|
});
|
|
}
|
|
function enc(ps) {
|
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
|
}
|
|
|
|
describe('unified: death deactivates, dead skipped in rotation', () => {
|
|
test('dead PC not removed from turnOrderIds (stays in list, inactive)', () => {
|
|
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
|
let e = enc(ps);
|
|
e = { ...e, ...startEncounter(e).patch };
|
|
const orderBefore = e.turnOrderIds.slice();
|
|
// kill b
|
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
|
expect(e.turnOrderIds).toEqual(orderBefore);
|
|
});
|
|
|
|
test('dead PC skipped by nextTurn (isActive=false)', () => {
|
|
const ps = [pc('a', 20), pc('b', 15), pc('c', 10)];
|
|
let e = enc(ps);
|
|
e = { ...e, ...startEncounter(e).patch };
|
|
// kill b
|
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
|
// advance: a→c (b skipped, inactive)
|
|
e = { ...e, ...nextTurn(e).patch };
|
|
expect(e.currentTurnParticipantId).toBe('c');
|
|
});
|
|
|
|
test('dead PC deathSave fires on manual call (not via rotation)', () => {
|
|
const ps = [pc('a', 20), pc('b', 15)];
|
|
let e = enc(ps);
|
|
e = { ...e, ...startEncounter(e).patch };
|
|
// kill b (current = a)
|
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
|
// b is dead: DM can still fire deathSave (manual action)
|
|
const r = deathSave(e, 'b', 'fail', 1);
|
|
expect(r.patch).toBeTruthy();
|
|
const b = r.patch.participants.find(x => x.id === 'b');
|
|
expect(b.deathFails).toBe(1);
|
|
});
|
|
|
|
// 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', () => {
|
|
const ps = [pc('a', 20), pc('b', 15)];
|
|
let e = enc(ps);
|
|
e = { ...e, ...startEncounter(e).patch };
|
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
|
const b = e.participants.find(x => x.id === 'b');
|
|
expect(b.isActive).toBe(false);
|
|
});
|
|
|
|
test('revive (heal from 0) reactivates participant', () => {
|
|
const ps = [pc('a', 20), pc('b', 15)];
|
|
let e = enc(ps);
|
|
e = { ...e, ...startEncounter(e).patch };
|
|
e = { ...e, ...applyHpChange(e, 'b', 'damage', 100).patch };
|
|
const dead = e.participants.find(x => x.id === 'b');
|
|
expect(dead.isActive).toBe(false);
|
|
// heal b back up
|
|
e = { ...e, ...applyHpChange(e, 'b', 'heal', 50).patch };
|
|
const revived = e.participants.find(x => x.id === 'b');
|
|
expect(revived.isActive).toBe(true);
|
|
expect(revived.currentHp).toBe(50);
|
|
expect(revived.deathSaves).toBe(0);
|
|
});
|
|
});
|