Files
ttrpg-initiative-tracker/shared/tests/turn.deathsave.undo.test.js
T

196 lines
8.1 KiB
JavaScript
Raw Normal View History

// Undo roundtrip for DEATH paths: damage to 0 HP (dying/dead), deathSave,
// stabilize, revive, dead-monster auto-deactivate. Each must restore full
// prior state including status, death-save counters, conditions (unconscious),
// and isActive.
//
// Pattern: apply op (writes encounter + log) -> read last log -> expandUndo
// against newEnc -> merge -> deepEqual original snapshot.
const shared = require('@ttrpg/shared');
const { mockCtx } = require('./_helpers');
const { expandUndo } = shared;
const {
makeParticipant, startEncounter,
applyHpChange, deathSave, stabilizeParticipant, reviveParticipant,
} = shared;
function char(id, hp = 100) {
return makeParticipant({
id, name: id, type: 'character',
initiative: 10, maxHp: hp, currentHp: hp,
});
}
function mon(id, hp = 100) {
return makeParticipant({
id, name: id, type: 'monster',
initiative: 10, maxHp: hp, currentHp: hp,
});
}
function enc(ps) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
}
const snap = (e) => JSON.parse(JSON.stringify(e));
function lastUndo(storage, enc) {
const logs = storage.logs();
const last = logs[logs.length - 1];
expect(last.undo).toBeTruthy();
const expanded = expandUndo(last, enc);
expect(expanded).toBeTruthy();
return expanded.updates;
}
describe('death-path undo roundtrip', () => {
test('damage to 0 HP (dying) undo restores status + unconscious condition', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 100, ctx);
const p = newEnc.participants.find(x => x.id === 'a');
expect(p.status).toBe('dying');
expect(p.conditions).toContain('unconscious');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('damage while dying undo restores status + failures + conditions', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 5, ctx); // +1 fail
expect(newEnc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('damage while stable undo restores stable + reset failures', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await stabilizeParticipant(e, 'a', ctx); // -> stable
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx); // -> dying 1 fail
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('massive damage (dead) undo restores conscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a', 50)]);
e = await startEncounter(e, ctx);
const before = snap(e);
const newEnc = await applyHpChange(e, 'a', 'damage', 200, ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('dead');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('dead monster auto-deactivate undo restores active', async () => {
const { storage, ctx } = mockCtx();
let e = enc([mon('a')]);
e = await startEncounter(e, ctx);
// first make it dead+inactive
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dead + inactive
// second damage at 0 while dead: no-op (returns same enc) but if it had
// repaired a stale active-dead monster it writes deactivate_dead_monster.
// Force the repair path: set stale isActive=true then damage.
e = { ...e, participants: e.participants.map(p => p.id === 'a' ? { ...p, isActive: true } : p) };
const before = snap(e); // stale active-dead = state right before the op
const newEnc = await applyHpChange(e, 'a', 'damage', 1, ctx);
expect(newEnc.participants.find(x => x.id === 'a').isActive).toBe(false);
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave fail undo restores dying + counters + conditions', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'fail', ctx);
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(1);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave nat1 (2 fails) undo restores prior counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'nat1', ctx);
expect(r.enc.participants.find(x => x.id === 'a').deathSaveFailures).toBe(2);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave nat20 (conscious) undo restores dying + unconscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
const before = snap(e);
const r = await deathSave(e, 'a', 'nat20', ctx);
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('conscious');
expect(r.enc.participants.find(x => x.id === 'a').currentHp).toBe(1);
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('deathSave to stable undo restores dying + unconscious', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
e = await deathSave(e, 'a', 'success', ctx).then(x => x.enc);
const before = snap(e); // 2 successes, dying
const r = await deathSave(e, 'a', 'success', ctx); // -> stable
expect(r.enc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, r.enc);
const after = { ...r.enc, ...undo };
expect(snap(after)).toEqual(before);
});
test('stabilize undo restores dying + unconscious + counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 100, ctx); // -> dying
e = await deathSave(e, 'a', 'fail', ctx).then(x => x.enc); // 1 fail
const before = snap(e);
const newEnc = await stabilizeParticipant(e, 'a', ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
test('revive undo restores dead + isActive + counters', async () => {
const { storage, ctx } = mockCtx();
let e = enc([char('a')]);
e = await startEncounter(e, ctx);
e = await applyHpChange(e, 'a', 'damage', 200, ctx); // -> dead (active)
const before = snap(e);
const newEnc = await reviveParticipant(e, 'a', ctx);
expect(newEnc.participants.find(x => x.id === 'a').status).toBe('stable');
const undo = lastUndo(storage, newEnc);
const after = { ...newEnc, ...undo };
expect(snap(after)).toEqual(before);
});
});