- 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
86 lines
3.0 KiB
JavaScript
86 lines
3.0 KiB
JavaScript
'use strict';
|
|
|
|
const shared = require('@ttrpg/shared');
|
|
const { makeParticipant, startEncounter, nextTurn, applyHpChange, deathSave } = shared;
|
|
const { mockCtx } = require('./_helpers');
|
|
|
|
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:[] };
|
|
}
|
|
const byId = (e, id) => e.participants.find(p => p.id === id);
|
|
|
|
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();
|
|
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
|
e = await startEncounter(e, ctx);
|
|
const orderBefore = e.turnOrderIds.slice();
|
|
|
|
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 remains in participants and turnOrderIds after three failures', async () => {
|
|
const { ctx } = mockCtx();
|
|
let e = enc([pc('a', 20), pc('b', 15), pc('c', 10)]);
|
|
e = await startEncounter(e, ctx);
|
|
const orderBefore = e.turnOrderIds.slice();
|
|
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;
|
|
|
|
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('b');
|
|
expect(byId(e, 'b').status).toBe('dead');
|
|
});
|
|
|
|
test('deathSave rejected/no-op once dead', async () => {
|
|
const { ctx } = mockCtx();
|
|
let e = enc([pc('a', 20), pc('b', 15)]);
|
|
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;
|
|
const before = byId(e, 'b');
|
|
|
|
try { e = (await deathSave(e, 'b', 'fail', ctx)).enc; } catch (err) {}
|
|
|
|
expect(byId(e, 'b')).toMatchObject(before);
|
|
});
|
|
});
|