196 lines
8.1 KiB
JavaScript
196 lines
8.1 KiB
JavaScript
// 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);
|
||
|
|
});
|
||
|
|
});
|