From bf1fccfd3b764c64b019481c3614852480582f0a Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:30:51 -0400 Subject: [PATCH] Exclude pause/resume from undo; fix verify round-wrap false positive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit togglePause no longer writes log entry (shared/turn.js). Lifecycle op, not player action. Undo stack now targets last real action, not flag flip. Removed dead pause/resume cases from expandUndo. verify.js false positive: round wrap via non-nextTurn event (removeParticipant) not detected — cycleActed never reset, actors flagged as acted_twice. Fix: detect round change on ANY event, drain removed/inactive before finalize (avoids false skipped when actor removed mid-round). Tests updated: togglePause logging test asserts no log; undo test asserts excluded. Repro confirmed via minimal verify case (removeParticipant r1->r2). --- scripts/combat/verify.js | 13 ++++++++++++- shared/tests/turn.logging.test.js | 5 ++--- shared/tests/turn.undo.test.js | 10 ++++------ shared/turn.js | 18 +++--------------- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/scripts/combat/verify.js b/scripts/combat/verify.js index cb12812..d217268 100644 --- a/scripts/combat/verify.js +++ b/scripts/combat/verify.js @@ -220,7 +220,18 @@ function checkCycles(steps, rounds) { if (started && pre && post.currentTurnParticipantId && pre.currentTurnParticipantId !== post.currentTurnParticipantId) { const wrapped = pre.round !== undefined && post.round !== undefined && post.round !== pre.round; - if (wrapped && isNextTurn) { + if (wrapped) { + // drain removed/inactive before finalizing so removed actors + // aren't flagged as skipped. + if (pre && pre.activeIds && post.activeIds) { + const postSet = new Set(post.activeIds); + for (const id of [...cycleActive]) { + if (!postSet.has(id)) { + cycleActive.delete(id); + cycleActed.delete(id); + } + } + } finalize(s.step); cycleRound = post.round; cycleActive = new Set(post.activeIds || []); diff --git a/shared/tests/turn.logging.test.js b/shared/tests/turn.logging.test.js index 6da715e..768d747 100644 --- a/shared/tests/turn.logging.test.js +++ b/shared/tests/turn.logging.test.js @@ -61,12 +61,11 @@ describe('Logging contract: mutating ops', () => { expectLastLogged(storage); }); - test('togglePause logs', async () => { + test('togglePause does NOT log (lifecycle excluded from undo)', async () => { const e = enc([p('a', 10), p('b', 7), p('c', 3)]); const started = await startEncounter(e, ctx); await togglePause(started, ctx); - expect(storage.logs()).toHaveLength(2); - expectLastLogged(storage); + expect(storage.logs()).toHaveLength(1); // start only }); test('addParticipant logs', async () => { diff --git a/shared/tests/turn.undo.test.js b/shared/tests/turn.undo.test.js index aa74d73..ecc84f3 100644 --- a/shared/tests/turn.undo.test.js +++ b/shared/tests/turn.undo.test.js @@ -66,18 +66,16 @@ describe('undo + redo roundtrip', () => { expect(snap(afterRedo)).toEqual(snap(newEnc)); }); - test('togglePause', async () => { + test('togglePause excluded from undo (no log entry)', 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 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)); + // state flips (isPaused), but no log written + expect(newEnc.isPaused).toBe(true); + expect(storage.logs()).toHaveLength(1); // start only }); test('applyHpChange damage restores HP on undo, re-applies on redo', async () => { diff --git a/shared/turn.js b/shared/turn.js index cb47370..5ad7bf2 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -304,14 +304,6 @@ function expandUndo(entry, currentEnc) { redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, }; } - case 'pause': - case 'resume': { - return { - encounterPath: encPath, - updates: { isPaused: u.isPaused }, - redo: { isPaused: !u.isPaused }, - }; - } case 'start_encounter': case 'end_encounter': { return { @@ -456,13 +448,9 @@ async function togglePause(encounter, ctx) { if (!encounter || !encounter.isStarted) throw new Error('Encounter not started.'); const newPausedState = !encounter.isPaused; const patch = { isPaused: newPausedState, turnOrderIds: encounter.turnOrderIds }; - const log = { - type: newPausedState ? 'pause' : 'resume', - message: `Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, - delta: { paused: newPausedState }, - undo: { isPaused: encounter.isPaused ?? false }, - }; - return commit(encounter, patch, log, ctx); + // Lifecycle op: no log entry. Pause/resume excluded from undo stack so DM + // undo targets the last player action (damage/condition/etc), not a flag flip. + return commit(encounter, patch, null, ctx); } async function addParticipant(encounter, participant, ctx) {