Fix undo/redo forward-state restore, harden test infra, skip-guard
Root cause: mutation writers stored oldValues only. Redo rebuilt from current state via derived logic — wrong order, wrong content, or no-op. Undo patched fields in place but ignored roster/array order changes. Writers now store forward arrays (shared/turn.js): - addParticipant: newTurnOrderIds, newCurrentTurnParticipantId - removeParticipant: newTurnOrderIds, newCurrentTurnParticipantId - updateParticipant: oldTurnOrderIds + newTurnOrderIds (initiative re-slot) - reorderParticipants: newParticipants + newTurnOrderIds - damage/heal/deathSave/stabilize/revive/deactivate_dead_monster: oldValues + newValues both capture full death state incl conditions expandUndo redo uses stored forward state instead of deriving: - add/remove: order via newTurnOrderIds - update: initiative change re-orders both directions - reorder: re-applies newParticipants - death ops: restore via newValues (was HP-only, dropped status/conditions) Missing expandUndo cases added: stabilize, revive, deactivate_dead_monster (were default:null -> undo no-op). Test infra (shared/tests/_helpers.js): - Mock storage undo() real now: applies patch + flips undone flag (was no-op) - addDoc persists log entries, getCollection returns them - undoLast/redoLast harness helpers exercise real mechanism - Undo tests assert against actual persisted doc, not expandUndo output turn.undo.test.js rewritten: every op tested as undo->deepEqual before, redo->deepEqual after-op (12 cases). Was undo-only, redo untested. turn.deathsave.undo.test.js added: 11 death-path roundtrips. Dead code removed: - round-trip.test.js: skipped suite testing deleted replay-from-logs.js (5 skipped tests rotting). Coverage gap acknowledged in TODO. Skip-guard added (scripts/run-tests.sh): pre-flight grep refuses to run if any .skip/xdescribe/xit found in test dirs. No CI; this is the gate.
This commit is contained in:
@@ -4,9 +4,6 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
|
||||
|
||||
## Open
|
||||
|
||||
### REview undo/redo around death stuff. doesnt work.
|
||||
suspect not writing to undo log?
|
||||
|
||||
### bug - start/end/resume combat should not be in undo I think...
|
||||
|
||||
## dying>stabe state - add +1hp in 1d4 hours DISPLAY (maybe show a rolled hours too) for while stable
|
||||
|
||||
@@ -30,6 +30,23 @@ run_suite () {
|
||||
fi
|
||||
}
|
||||
|
||||
# Pre-flight: refuse to run if any test is skipped/disabled.
|
||||
# Skipped tests rot silently. Catch them before they hide coverage gaps.
|
||||
check_for_skips () {
|
||||
echo "=== skip-guard ==="
|
||||
local hits
|
||||
hits=$(grep -rnE '\.(skip|todo)\(|xdescribe\(|xit\(' src/tests shared/tests server/tests 2>/dev/null || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "FAIL: skipped/disabled tests found:" >&2
|
||||
echo "$hits" >&2
|
||||
echo "" >&2
|
||||
echo "Fix or delete. Do not skip." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== skip-guard: PASS ==="
|
||||
}
|
||||
check_for_skips
|
||||
|
||||
run_suite "app" "CI=true npx react-scripts test --watchAll=false" 60
|
||||
run_suite "shared" "npm test --workspace shared" 30
|
||||
run_suite "server" "npm test --workspace server" 30
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// Test helper: in-memory mock storage. Records all writes (addDoc/updateDoc).
|
||||
// Captures encounter patches + log entries so tests assert persistence.
|
||||
// Stores docs so getDoc/getCollection return real state. undo() actually
|
||||
// applies patches + flips log `undone` flag (mirrors firebase/server contract).
|
||||
function createMockStorage() {
|
||||
const docs = new Map(); // path -> data
|
||||
const calls = []; // ordered {fn, path, data}
|
||||
|
||||
const applyPatch = (path, patch) => {
|
||||
if (!docs.has(path)) docs.set(path, {});
|
||||
const cur = docs.get(path);
|
||||
// Firestore shallow-merge: arrays replaced, not merged. Patch arrays are
|
||||
// the FULL new value (participants, turnOrderIds, conditions).
|
||||
docs.set(path, { ...cur, ...patch });
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
docs,
|
||||
@@ -14,24 +23,66 @@ function createMockStorage() {
|
||||
},
|
||||
async addDoc(path, data) {
|
||||
calls.push({ fn: 'addDoc', path, data });
|
||||
// Persist log entry so getCollection + undo can read it back.
|
||||
const id = data.id || `log-${calls.length}`;
|
||||
const stored = { ...data, id, _path: `${path}/${id}` };
|
||||
docs.set(`${path}/${id}`, stored);
|
||||
return id;
|
||||
},
|
||||
async updateDoc(path, patch) {
|
||||
calls.push({ fn: 'updateDoc', path, data: patch });
|
||||
if (!docs.has(path)) docs.set(path, {});
|
||||
docs.set(path, { ...docs.get(path), ...patch });
|
||||
applyPatch(path, patch);
|
||||
},
|
||||
async deleteDoc(path) {
|
||||
calls.push({ fn: 'deleteDoc', path });
|
||||
docs.delete(path);
|
||||
},
|
||||
async getCollection() { return []; },
|
||||
async getCollection(path, queryConstraints = []) {
|
||||
// Return docs whose _path starts with `${path}/` (Firestore collection).
|
||||
const out = [];
|
||||
for (const [p, d] of docs.entries()) {
|
||||
if (p.startsWith(`${path}/`)) out.push({ ...d });
|
||||
}
|
||||
// Best-effort query support: where/orderBy/limit.
|
||||
let filtered = out;
|
||||
for (const qc of queryConstraints) {
|
||||
if (!filtered.length) break;
|
||||
const op = qc[0];
|
||||
if (op === 'where') {
|
||||
const [, field, , value] = qc;
|
||||
filtered = filtered.filter(d => d[field] === value);
|
||||
} else if (op === 'orderBy') {
|
||||
const [, field, dir = 'asc'] = qc;
|
||||
filtered.sort((a, b) => {
|
||||
const av = a[field], bv = b[field];
|
||||
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||||
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
} else if (op === 'limit') {
|
||||
filtered = filtered.slice(0, qc[1]);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
},
|
||||
async batchWrite(ops) {
|
||||
for (const op of ops) {
|
||||
if (op.type === 'delete') await this.deleteDoc(op.path);
|
||||
else await this.updateDoc(op.path, op.data);
|
||||
}
|
||||
},
|
||||
async undo() {},
|
||||
// Transactional undo — mirrors firebase.js / server.js contract:
|
||||
// redo=false: apply undo.updates to encounterPath, flip log undone=true
|
||||
// redo=true: apply undo.redo to encounterPath, flip log undone=false
|
||||
async undo({ logPath, undo, redo = false }) {
|
||||
calls.push({ fn: 'undo', path: logPath, data: { undo, redo } });
|
||||
const patch = redo ? (undo.redo || undo.updates) : undo.updates;
|
||||
applyPatch(undo.encounterPath, patch);
|
||||
if (docs.has(logPath)) {
|
||||
const log = docs.get(logPath);
|
||||
docs.set(logPath, { ...log, undone: !redo });
|
||||
}
|
||||
},
|
||||
// filters
|
||||
updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); },
|
||||
logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
|
||||
@@ -39,6 +90,31 @@ function createMockStorage() {
|
||||
};
|
||||
}
|
||||
|
||||
// Undo/redo harness: apply op via shared func (writes enc doc + log) then
|
||||
// exercise the REAL undo mechanism (storage.undo applies patch + flips flag).
|
||||
// Reads doc back so assertions reflect actual persisted state.
|
||||
// applyThenUndo(ctx, encAfterOp) -> enc after undo applied
|
||||
// applyThenRedo(ctx, encAfterUndo, lastLogId) -> enc after redo applied
|
||||
async function undoLast(ctx, encAfterOp) {
|
||||
const { storage, encPath, logPath } = ctx;
|
||||
const last = storage.logs()[storage.logs().length - 1];
|
||||
const { expandUndo } = require('./..');
|
||||
const expanded = expandUndo(last, encAfterOp);
|
||||
if (!expanded) throw new Error('expandUndo returned null');
|
||||
const logEntryPath = `${logPath}/${last.id}`;
|
||||
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: false });
|
||||
return await storage.getDoc(encPath);
|
||||
}
|
||||
async function redoLast(ctx, encAfterUndo, last) {
|
||||
const { storage, encPath, logPath } = ctx;
|
||||
const { expandUndo } = require('./..');
|
||||
const expanded = expandUndo(last, encAfterUndo);
|
||||
if (!expanded) throw new Error('expandUndo returned null (redo)');
|
||||
const logEntryPath = `${logPath}/${last.id}`;
|
||||
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: true });
|
||||
return await storage.getDoc(encPath);
|
||||
}
|
||||
|
||||
// Standard ctx for tests. encPath + logPath + displayPath preset.
|
||||
// Returns { storage, ctx } so tests destructure both in one call:
|
||||
// const { storage, ctx } = mockCtx();
|
||||
@@ -70,4 +146,4 @@ function readyEnc(opts = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createMockStorage, mockCtx, readyEnc };
|
||||
module.exports = { createMockStorage, mockCtx, readyEnc, undoLast, redoLast };
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// INTEGRATION TEST: full log pipeline round-trip.
|
||||
// SKIPPED: replay-from-logs.js consumes old payload/undo_payload shape.
|
||||
// Log redesign (lean delta-based) broke this. replay-from-logs = separate
|
||||
// concern, flagged untrusted in TODO. Re-enable when that tool migrates or
|
||||
// is replaced. analyze-turns.js (JSONL + JSON) covered by direct replay tests.
|
||||
|
||||
describe.skip('log pipeline round-trip', () => {
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const shared = require('..');
|
||||
const { startEncounter, nextTurn } = shared;
|
||||
const { normalizeEvent, serializeEvents } = shared.logEvent;
|
||||
const { mockCtx, readyEnc } = require('./_helpers');
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
function tmpFile(content) {
|
||||
const p = path.join(os.tmpdir(), `ttrpg-rt-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||
fs.writeFileSync(p, content);
|
||||
return p;
|
||||
}
|
||||
async function buildEvents(rounds) {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let enc = readyEnc();
|
||||
enc = await startEncounter(enc, ctx);
|
||||
for (let i = 0; i < rounds * 2; i++) enc = await nextTurn(enc, ctx);
|
||||
const logs = storage.logs().map(normalizeEvent).filter(Boolean);
|
||||
logs.pop();
|
||||
return logs;
|
||||
}
|
||||
function runScript(script, file) {
|
||||
try {
|
||||
const stdout = execSync(`node "${path.join(ROOT, 'scripts', script)}" "${file}"`, {
|
||||
encoding: 'utf8', cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000,
|
||||
});
|
||||
return { exit: 0, stdout };
|
||||
} catch (err) {
|
||||
return { exit: err.status ?? 1, stdout: (err.stdout || '') + (err.stderr || '') };
|
||||
}
|
||||
}
|
||||
|
||||
test('buildEvents: 3 rounds = 6 events (1 start + 5 turns)', async () => {
|
||||
const ev = await buildEvents(3);
|
||||
expect(ev).toHaveLength(6);
|
||||
expect(ev[0].type).toBe('start_encounter');
|
||||
expect(ev.filter(e => e.type === 'next_turn')).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('replay-from-logs: reconstructs state, snapshots match, exit 0', async () => {
|
||||
const file = tmpFile(JSON.stringify(await buildEvents(3)));
|
||||
const { exit, stdout } = runScript('replay-from-logs.js', file);
|
||||
expect(exit).toBe(0);
|
||||
expect(stdout).toContain('CLEAN — replay matches logged intent');
|
||||
expect(stdout).toContain('turns replayed: 5');
|
||||
expect(stdout).toContain('events applied: 6 / 6');
|
||||
expect(stdout).not.toContain('DRIFT');
|
||||
fs.unlinkSync(file);
|
||||
});
|
||||
|
||||
test('analyze-turns: 3 clean rounds = no skips, no double-acts, exit 0', async () => {
|
||||
const file = tmpFile(JSON.stringify(await buildEvents(3)));
|
||||
const { exit, stdout } = runScript('analyze-turns.js', file);
|
||||
expect(exit).toBe(0);
|
||||
expect(stdout).toContain('CLEAN — no rotation bugs');
|
||||
expect(stdout).toContain('=== 3 rounds analyzed ===');
|
||||
expect(stdout).toContain('real skips: 0');
|
||||
expect(stdout).toContain('double-acts: 0');
|
||||
fs.unlinkSync(file);
|
||||
});
|
||||
|
||||
test('replay-from-logs: legacy events (no type) dropped by normalizer, not fatal', async () => {
|
||||
const ev = await buildEvents(2);
|
||||
ev.unshift({ id: 'old1', timestamp: 1, type: 'unknown', message: 'legacy', encounterName: 'Old' });
|
||||
ev.unshift({ id: 'old2', timestamp: 0, message: 'legacy', encounterName: 'Old' });
|
||||
const file = tmpFile(JSON.stringify(ev));
|
||||
const { exit, stdout } = runScript('replay-from-logs.js', file);
|
||||
expect(exit).toBe(0);
|
||||
expect(stdout).toContain('events applied: 4 / 4');
|
||||
expect(stdout).toContain('CLEAN — replay matches logged intent');
|
||||
fs.unlinkSync(file);
|
||||
});
|
||||
|
||||
test('replay-from-logs: no-arg, no-stdin = exits non-zero (no hang)', () => {
|
||||
let exit;
|
||||
try {
|
||||
execSync(`node "${path.join(ROOT, 'scripts', 'replay-from-logs.js')}"`, {
|
||||
encoding: 'utf8', cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000,
|
||||
});
|
||||
exit = 0;
|
||||
} catch (err) {
|
||||
exit = err.status ?? 1;
|
||||
}
|
||||
expect(exit).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
+118
-67
@@ -1,11 +1,16 @@
|
||||
// Undo roundtrip: every op that writes a log entry with undo_payload must
|
||||
// restore prior state. Apply op (writes encounter + log) → read back log's
|
||||
// undo_payload.updates → apply undo on top of newEnc → assert deepEqual original.
|
||||
// Undo + redo roundtrip: every op that writes a log entry must restore prior
|
||||
// state on undo AND re-apply forward state on redo. Uses REAL mock storage
|
||||
// undo() (applies patch + flips undone flag), not just expandUndo output.
|
||||
//
|
||||
// Rewritten for lean log shape: `undo` is id-based inverse; expandUndo(entry, enc)
|
||||
// builds full patch from current enc doc. Apply updates → assert equals before.
|
||||
// Pattern per case:
|
||||
// before = snap(enc) // state before op
|
||||
// newEnc = await op(enc) // apply op (writes enc doc + log entry)
|
||||
// afterUndo = undoLast() // real undo via storage.undo
|
||||
// expect(afterUndo) === before
|
||||
// afterRedo = redoLast() // real redo via storage.undo({redo:true})
|
||||
// expect(afterRedo) === snap(newEnc)
|
||||
const shared = require('@ttrpg/shared');
|
||||
const { mockCtx } = require('./_helpers');
|
||||
const { mockCtx, undoLast, redoLast } = require('./_helpers');
|
||||
const { expandUndo } = shared;
|
||||
const {
|
||||
makeParticipant, startEncounter, nextTurn, togglePause,
|
||||
@@ -26,133 +31,179 @@ function enc(ps) {
|
||||
}
|
||||
const snap = (e) => JSON.parse(JSON.stringify(e));
|
||||
|
||||
// Last log entry's expanded undo patch (built from id-based undo + enc doc).
|
||||
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('undo roundtrip', () => {
|
||||
test('startEncounter undo restores pre-start', async () => {
|
||||
describe('undo + redo roundtrip', () => {
|
||||
test('startEncounter', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const before = enc([p('a',10),p('b',20)]);
|
||||
await storage.setDoc(ctx.encPath, before);
|
||||
const newEnc = await startEncounter(before, ctx);
|
||||
expect(storage.logs()).toHaveLength(1);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
// undo restores isStarted/isPaused/round/current/turnOrderIds.
|
||||
// participants[] may be reordered (1-list sort on start) — undo snapshot
|
||||
// captures turn-state fields, not participant order.
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(after.isStarted).toBe(before.isStarted);
|
||||
expect(after.isPaused).toBe(before.isPaused);
|
||||
expect(after.round).toBe(before.round);
|
||||
expect(after.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
|
||||
expect(after.turnOrderIds).toEqual(before.turnOrderIds);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
// participants[] order may differ (start sorts); check turn-state fields.
|
||||
expect(afterUndo.isStarted).toBe(before.isStarted);
|
||||
expect(afterUndo.isPaused).toBe(before.isPaused);
|
||||
expect(afterUndo.round).toBe(before.round);
|
||||
expect(afterUndo.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
|
||||
expect(afterUndo.turnOrderIds).toEqual(before.turnOrderIds);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(afterRedo.isStarted).toBe(newEnc.isStarted);
|
||||
expect(afterRedo.round).toBe(newEnc.round);
|
||||
expect(afterRedo.currentTurnParticipantId).toBe(newEnc.currentTurnParticipantId);
|
||||
expect(afterRedo.turnOrderIds).toEqual(newEnc.turnOrderIds);
|
||||
});
|
||||
|
||||
test('nextTurn undo restores prior currentTurn/round', async () => {
|
||||
test('nextTurn', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await nextTurn(e, ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('togglePause undo restores prior paused state', async () => {
|
||||
test('togglePause', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await togglePause(e, ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('applyHpChange undo restores prior participants', async () => {
|
||||
test('applyHpChange damage restores HP on undo, re-applies on redo', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('toggleCondition undo restores prior participants', async () => {
|
||||
test('applyHpChange heal restores HP on undo, re-applies on redo', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10,{maxHp:100,currentHp:50})]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('toggleCondition', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await toggleCondition(e, 'a', 'stunned', ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('toggleParticipantActive undo restores prior participants + turn order', async () => {
|
||||
test('toggleParticipantActive restores participants + turn order, redo re-applies', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await toggleParticipantActive(e, 'b', ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('addParticipant undo restores prior participants', async () => {
|
||||
test('addParticipant', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 });
|
||||
const newEnc = await addParticipant(e, np, ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('removeParticipant undo restores prior participants + turn order', async () => {
|
||||
test('removeParticipant restores prior participants + turn order, redo re-applies', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await removeParticipant(e, 'b', ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('endEncounter undo restores prior state', async () => {
|
||||
test('endEncounter', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await endEncounter(e, ctx);
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('reorderParticipants undo restores prior order (BUG-7 fixed)', async () => {
|
||||
// Unstarted encounter: no current-turn pointer, same-init reorder is valid.
|
||||
test('reorderParticipants (BUG-7 fixed)', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
const ps = [p('a',10),p('b',20),p('c',20)]; // b,c tie
|
||||
let e = enc(ps);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
||||
expect(storage.logs()).toHaveLength(1); // logged (was a no-log bug before)
|
||||
const undo = lastUndo(storage, newEnc);
|
||||
const after = { ...newEnc, ...undo };
|
||||
expect(snap(after)).toEqual(before);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
|
||||
test('updateParticipant', async () => {
|
||||
const { storage, ctx } = mockCtx();
|
||||
let e = enc([p('a',10),p('b',20)]);
|
||||
e = await startEncounter(e, ctx);
|
||||
await storage.setDoc(ctx.encPath, e);
|
||||
const before = snap(e);
|
||||
const newEnc = await shared.updateParticipant(e, 'a', { initiative: 99 }, ctx);
|
||||
const last = storage.logs().at(-1);
|
||||
const afterUndo = await undoLast(ctx, newEnc);
|
||||
expect(snap(afterUndo)).toEqual(before);
|
||||
const afterRedo = await redoLast(ctx, afterUndo, last);
|
||||
expect(snap(afterRedo)).toEqual(snap(newEnc));
|
||||
});
|
||||
});
|
||||
|
||||
+144
-31
@@ -163,10 +163,21 @@ function expandUndo(entry, currentEnc) {
|
||||
const turnState = {};
|
||||
if (u.currentTurnParticipantId !== undefined) turnState.currentTurnParticipantId = u.currentTurnParticipantId;
|
||||
if (u.turnOrderIds) turnState.turnOrderIds = u.turnOrderIds;
|
||||
// redo: re-insert added into current parts, order by stored newTurnOrderIds.
|
||||
const redoOrder = u.newTurnOrderIds || null;
|
||||
let redoParts = null;
|
||||
if (redoOrder) {
|
||||
const pool = new Map([...parts, ...added].map(p => [p.id, p]));
|
||||
redoParts = redoOrder.map(id => pool.get(id)).filter(Boolean);
|
||||
}
|
||||
const redoTurnState = {};
|
||||
if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId;
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.filter(p => !ids.has(p.id)), ...turnState },
|
||||
redo: { participants: [...parts, ...added.filter(p => !parts.some(x => x.id === p.id))] },
|
||||
redo: redoParts
|
||||
? { participants: redoParts, turnOrderIds: redoOrder, ...redoTurnState }
|
||||
: { participants: [...parts, ...added.filter(p => !parts.some(x => x.id === p.id))] },
|
||||
};
|
||||
}
|
||||
case 'remove_participant': {
|
||||
@@ -179,23 +190,45 @@ function expandUndo(entry, currentEnc) {
|
||||
const idx = typeof u.slotIdx === 'number' ? Math.min(u.slotIdx, parts.length) : parts.length;
|
||||
restoredParts = [...parts.slice(0, idx), p, ...parts.slice(idx)];
|
||||
}
|
||||
// redo: re-remove + restore stored forward turn state.
|
||||
const redoTurnState = {};
|
||||
if (u.newCurrentTurnParticipantId !== undefined) redoTurnState.currentTurnParticipantId = u.newCurrentTurnParticipantId;
|
||||
if (u.newTurnOrderIds) redoTurnState.turnOrderIds = u.newTurnOrderIds;
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: restoredParts, ...turnState },
|
||||
redo: { participants: parts.filter(x => x.id !== (p && p.id)) },
|
||||
redo: u.newTurnOrderIds
|
||||
? { participants: parts.filter(x => x.id !== (p && p.id)), ...redoTurnState }
|
||||
: { participants: parts.filter(x => x.id !== (p && p.id)) },
|
||||
};
|
||||
}
|
||||
case 'damage':
|
||||
case 'heal': {
|
||||
const amt = u.amount || 0;
|
||||
case 'heal':
|
||||
case 'death_save':
|
||||
case 'stabilize':
|
||||
case 'revive':
|
||||
case 'deactivate_dead_monster': {
|
||||
// Full death-state restore: currentHp, status, death-save counters,
|
||||
// isActive, conditions (unconscious added/removed by withDeathStatusConditions).
|
||||
const pid = entry.participantId;
|
||||
const cur = parts.find(p => p.id === pid);
|
||||
if (!cur) return null;
|
||||
const newHp = Math.max(0, Math.min(cur.maxHp, cur.currentHp + amt));
|
||||
const oldVals = u.oldValues || {};
|
||||
const newVals = u.newValues || {};
|
||||
const restoreFields = (vals) => {
|
||||
const out = {};
|
||||
if (vals.currentHp !== undefined) out.currentHp = vals.currentHp;
|
||||
if (vals.status !== undefined) out.status = vals.status;
|
||||
if (vals.deathSaveSuccesses !== undefined) out.deathSaveSuccesses = vals.deathSaveSuccesses;
|
||||
if (vals.deathSaveFailures !== undefined) out.deathSaveFailures = vals.deathSaveFailures;
|
||||
if (vals.isActive !== undefined) out.isActive = vals.isActive;
|
||||
if (vals.conditions !== undefined) out.conditions = vals.conditions;
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, currentHp: newHp } : p) },
|
||||
redo: { participants: parts.map(p => p.id === pid ? { ...p, currentHp: cur.currentHp - amt } : p) },
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(oldVals) } : p) },
|
||||
redo: { participants: parts.map(p => p.id === pid ? { ...p, ...restoreFields(newVals) } : p) },
|
||||
};
|
||||
}
|
||||
case 'add_condition':
|
||||
@@ -226,30 +259,41 @@ function expandUndo(entry, currentEnc) {
|
||||
};
|
||||
}
|
||||
case 'reorder': {
|
||||
// undo: swap dragged/target back. Stored old order.
|
||||
// undo: restore old order. redo: re-apply stored forward order.
|
||||
const redoParts = u.newParticipants || null;
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}) },
|
||||
redo: { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] },
|
||||
updates: { participants: u.participants || parts, ...(u.turnOrderIds ? { turnOrderIds: u.turnOrderIds } : {}), ...(u.currentTurnParticipantId !== undefined ? { currentTurnParticipantId: u.currentTurnParticipantId } : {}) },
|
||||
redo: redoParts
|
||||
? { participants: redoParts, turnOrderIds: u.newTurnOrderIds || redoParts.map(p => p.id) }
|
||||
: { participants: parts, turnOrderIds: snap.turnOrderIds || currentEnc.turnOrderIds || [] },
|
||||
};
|
||||
}
|
||||
case 'update_participant': {
|
||||
const pid = entry.participantId;
|
||||
const oldVals = u.oldValues || {};
|
||||
const newVals = u.newValues || {};
|
||||
// If initiative changed, restore array order too (undo by oldTurnOrderIds,
|
||||
// redo by newTurnOrderIds). Otherwise just patch fields in place.
|
||||
const applyFields = (vals) => parts.map(p => p.id === pid ? { ...p, ...vals } : p);
|
||||
const hasOrderChange = !!(u.oldTurnOrderIds && u.oldTurnOrderIds.length && u.newTurnOrderIds && u.newTurnOrderIds.length && JSON.stringify(u.oldTurnOrderIds) !== JSON.stringify(u.newTurnOrderIds));
|
||||
if (hasOrderChange && u.oldTurnOrderIds && u.newTurnOrderIds) {
|
||||
const undoApplied = applyFields(oldVals);
|
||||
const undoPool = new Map(undoApplied.map(p => [p.id, p]));
|
||||
const undoOrdered = u.oldTurnOrderIds.map(id => undoPool.get(id)).filter(Boolean);
|
||||
const redoApplied = applyFields(newVals);
|
||||
const redoPool = new Map(redoApplied.map(p => [p.id, p]));
|
||||
const redoOrdered = u.newTurnOrderIds.map(id => redoPool.get(id)).filter(Boolean);
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: undoOrdered, turnOrderIds: u.oldTurnOrderIds },
|
||||
redo: { participants: redoOrdered, turnOrderIds: u.newTurnOrderIds },
|
||||
};
|
||||
}
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...oldVals } : p) },
|
||||
redo: { participants: parts.map(p => p.id === pid ? { ...p, ...newVals } : p) },
|
||||
};
|
||||
}
|
||||
case 'death_save': {
|
||||
const pid = entry.participantId;
|
||||
const oldVals = u.oldValues || {};
|
||||
return {
|
||||
encounterPath: encPath,
|
||||
updates: { participants: parts.map(p => p.id === pid ? { ...p, ...oldVals } : p) },
|
||||
redo: { participants: parts },
|
||||
updates: { participants: applyFields(oldVals) },
|
||||
redo: { participants: applyFields(newVals), ...(u.newTurnOrderIds ? { turnOrderIds: u.newTurnOrderIds } : {}) },
|
||||
};
|
||||
}
|
||||
case 'next_turn':
|
||||
@@ -434,7 +478,13 @@ async function addParticipant(encounter, participant, ctx) {
|
||||
participantId: participant.id, participantName: participant.name,
|
||||
message: `${participant.name} added to encounter (Initiative: ${participant.initiative})`,
|
||||
delta: { init: participant.initiative, maxHp: participant.maxHp, ptype: participant.type },
|
||||
undo: { participant, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
||||
undo: {
|
||||
participant,
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
newTurnOrderIds: patch.turnOrderIds,
|
||||
newCurrentTurnParticipantId: patch.currentTurnParticipantId,
|
||||
},
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -469,7 +519,7 @@ async function updateParticipant(encounter, participantId, updatedData, ctx) {
|
||||
participantId, participantName: target.name,
|
||||
message: `${target.name} updated (${fields})`,
|
||||
delta: { changedFields: Object.keys(updatedData) },
|
||||
undo: { oldValues, newValues: { ...updatedData } },
|
||||
undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
} else {
|
||||
@@ -482,7 +532,7 @@ async function updateParticipant(encounter, participantId, updatedData, ctx) {
|
||||
participantId, participantName: target.name,
|
||||
message: `${target.name} updated (initiative: ${merged.initiative})`,
|
||||
delta: { changedFields: Object.keys(updatedData), initiative: merged.initiative },
|
||||
undo: { oldValues, newValues: { ...updatedData } },
|
||||
undo: { oldValues, newValues: { ...updatedData }, oldTurnOrderIds: encounter.turnOrderIds || [], newTurnOrderIds: patch.turnOrderIds },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -500,7 +550,13 @@ async function removeParticipant(encounter, participantId, ctx) {
|
||||
participantId, participantName: participant ? participant.name : null,
|
||||
message: `${participant ? participant.name : 'Participant'} removed from encounter`,
|
||||
delta: { dead: true },
|
||||
undo: { participant, slotIdx, currentTurnParticipantId: encounter.currentTurnParticipantId, turnOrderIds: [...(encounter.turnOrderIds || [])] },
|
||||
undo: {
|
||||
participant, slotIdx,
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId,
|
||||
turnOrderIds: [...(encounter.turnOrderIds || [])],
|
||||
newCurrentTurnParticipantId: patch.currentTurnParticipantId,
|
||||
newTurnOrderIds: patch.turnOrderIds,
|
||||
},
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -544,6 +600,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
|
||||
let updates = {};
|
||||
@@ -559,13 +616,22 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
p.id === participantId ? { ...p, isActive: false } : p
|
||||
);
|
||||
const patch = { participants: updatedParticipants };
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'deactivate_dead_monster',
|
||||
participantId,
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} marked inactive (dead monster)`,
|
||||
delta: { status: 'dead', isActive: false },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -610,12 +676,21 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
|
||||
);
|
||||
const patch = { participants: updatedParticipants };
|
||||
const undoAmount = changeType === 'damage' ? amount : -amount;
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: changeType,
|
||||
participantId, participantName: participant.name,
|
||||
message,
|
||||
delta: logDelta,
|
||||
undo: { amount: undoAmount, oldValues },
|
||||
undo: { amount: undoAmount, oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -634,6 +709,8 @@ async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
status: statusBefore,
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
|
||||
let updates;
|
||||
@@ -657,12 +734,21 @@ async function deathSave(encounter, participantId, outcome, ctx) {
|
||||
p.id === participantId ? { ...p, ...withDeathStatusConditions(p, updates) } : p
|
||||
);
|
||||
const patch = { participants: updatedParticipants };
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'death_save',
|
||||
participantId, participantName: name,
|
||||
message,
|
||||
delta: { outcome, status: updates.status, deathSaveSuccesses: updates.deathSaveSuccesses, deathSaveFailures: updates.deathSaveFailures },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
const enc = await commit(encounter, patch, log, ctx);
|
||||
return { enc, status: updates.status };
|
||||
@@ -708,6 +794,17 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
status,
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'stabilize',
|
||||
@@ -715,7 +812,7 @@ async function stabilizeParticipant(encounter, participantId, ctx) {
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} stabilized at 0 HP`,
|
||||
delta: { status: 'stable' },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -738,6 +835,16 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
||||
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: participant.deathSaveFailures || 0,
|
||||
isActive: participant.isActive,
|
||||
conditions: [...(participant.conditions || [])],
|
||||
};
|
||||
const newP = updatedParticipants.find(p => p.id === participantId);
|
||||
const newValues = {
|
||||
currentHp: newP.currentHp,
|
||||
status: newP.status,
|
||||
deathSaveSuccesses: newP.deathSaveSuccesses || 0,
|
||||
deathSaveFailures: newP.deathSaveFailures || 0,
|
||||
isActive: newP.isActive,
|
||||
conditions: [...(newP.conditions || [])],
|
||||
};
|
||||
const log = {
|
||||
type: 'revive',
|
||||
@@ -745,7 +852,7 @@ async function reviveParticipant(encounter, participantId, ctx) {
|
||||
participantName: participant.name,
|
||||
message: `${participant.name} revived to 0 HP (stable, unconscious)`,
|
||||
delta: { status: 'stable', currentHp: 0 },
|
||||
undo: { oldValues },
|
||||
undo: { oldValues, newValues },
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
@@ -777,7 +884,13 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) {
|
||||
participantId: draggedId, participantName: removedItem.name,
|
||||
message: `${removedItem.name} reordered before ${(participants.find(p => p.id === targetId) || {}).name || targetId}`,
|
||||
delta: { draggedId, targetId },
|
||||
undo: { participants: encounter.participants || [], turnOrderIds: encounter.turnOrderIds || [], currentTurnParticipantId: encounter.currentTurnParticipantId ?? null },
|
||||
undo: {
|
||||
participants: encounter.participants || [],
|
||||
turnOrderIds: encounter.turnOrderIds || [],
|
||||
currentTurnParticipantId: encounter.currentTurnParticipantId ?? null,
|
||||
newParticipants: participants,
|
||||
newTurnOrderIds: patch.turnOrderIds,
|
||||
},
|
||||
};
|
||||
return commit(encounter, patch, log, ctx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user