From 2c997de0dab12d0409481314c9cdfc3f76e3ba1e Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:09:28 -0400 Subject: [PATCH] Fix undo/redo forward-state restore, harden test infra, skip-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- TODO.md | 3 - scripts/run-tests.sh | 17 ++ shared/tests/_helpers.js | 88 +++++++++- shared/tests/round-trip.test.js | 95 ----------- shared/tests/turn.deathsave.undo.test.js | 195 +++++++++++++++++++++++ shared/tests/turn.undo.test.js | 185 +++++++++++++-------- shared/turn.js | 175 ++++++++++++++++---- 7 files changed, 556 insertions(+), 202 deletions(-) delete mode 100644 shared/tests/round-trip.test.js create mode 100644 shared/tests/turn.deathsave.undo.test.js diff --git a/TODO.md b/TODO.md index 6e42105..81cf7e3 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 0841d3d..46c585e 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -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 diff --git a/shared/tests/_helpers.js b/shared/tests/_helpers.js index ddf37b3..d373539 100644 --- a/shared/tests/_helpers.js +++ b/shared/tests/_helpers.js @@ -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 }; diff --git a/shared/tests/round-trip.test.js b/shared/tests/round-trip.test.js deleted file mode 100644 index 61599f7..0000000 --- a/shared/tests/round-trip.test.js +++ /dev/null @@ -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); - }); -}); diff --git a/shared/tests/turn.deathsave.undo.test.js b/shared/tests/turn.deathsave.undo.test.js new file mode 100644 index 0000000..7b2a7fb --- /dev/null +++ b/shared/tests/turn.deathsave.undo.test.js @@ -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); + }); +}); diff --git a/shared/tests/turn.undo.test.js b/shared/tests/turn.undo.test.js index 621f631..aa74d73 100644 --- a/shared/tests/turn.undo.test.js +++ b/shared/tests/turn.undo.test.js @@ -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)); }); }); diff --git a/shared/turn.js b/shared/turn.js index 67eaf06..cb47370 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -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); }