// 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); }); });