diff --git a/TODO.md b/TODO.md index c117de4..b3adffd 100644 --- a/TODO.md +++ b/TODO.md @@ -8,15 +8,6 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. - `reorderParticipants` returns `log: null`. Other ops return `log.undo`. - Cannot undo drag-drop. Candidate for undo system (M6). -### BUG-10: deact+reactivate same round double-acts participant -- Discovered in 500-round replay (3 occurrences). DISTINCT from BUG-5. -- Pattern: participant acts → DM deactivates them → DM reactivates them - same round → re-inserts by initiative (front) → acts AGAIN before round ends. -- No "acted-this-round" guard. Slot-array model has no per-round-acted set. -- Edge case (DM deact+reactivate same participant same round). -- Fix candidate: track actedThisRound set, skip re-acted; OR insertion - places reactivate AFTER current position (not by initiative). - ### BUG-13: reorderParticipants crossing current pointer = ambiguous acted-semantics - `reorderParticipants` (shared/turn.js) = pure drag, no pointer logic. - Swapping two actors across current pointer mid-round = ambiguous @@ -95,6 +86,10 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. ### BUG-8: server adapter has no reconnect - FIXED — onclose reconnects + re-subscribes existing paths. +### BUG-10: deact+reactivate same round double-acts participant +- FIXED — 1-list model keeps slot position on toggle. Reactivate does not + grant second turn. Test: turn.bug10.test.js. + ### BUG-11: FE Combat.scenario test crashes - FIXED — moved to shared/turn.combat.test.js, pure functions, 100 rounds. diff --git a/shared/tests/turn.bug10.test.js b/shared/tests/turn.bug10.test.js new file mode 100644 index 0000000..07f0ef3 --- /dev/null +++ b/shared/tests/turn.bug10.test.js @@ -0,0 +1,82 @@ +// BUG-10: deact+reactivate same round double-acts participant. +// OLD bug: reactivation re-inserted by initiative (front of pointer) → +// participant acted again before round end. +// +// 1-list model: toggleActive keeps slot position. Reactivation does NOT +// grant second turn. Each participant acts at most once per round. +// +// Test: walk rounds, count visits. Deactivate+reactivate mid-round must +// not cause any participant to act twice in same round. + +'use strict'; + +const shared = require('@ttrpg/shared'); +const { + makeParticipant, + startEncounter, nextTurn, toggleParticipantActive, +} = shared; + +function p(id, init) { + return makeParticipant({ id, name: id, type: 'monster', + initiative: init, maxHp: 100, currentHp: 100 }); +} +function enc(ps) { + return { name:'t', participants:ps, isStarted:false, isPaused:false, + round:0, currentTurnParticipantId:null, turnOrderIds:[] }; +} +const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e; + +describe('BUG-10: deact+reactivate same round', () => { + test('no participant acts twice in a round after deact+reactivate', () => { + // [a(10), b(7), c(3)] + let e = enc([p('a',10),p('b',7),p('c',3)]); + e = apply(e, startEncounter(e)); // a current, r1 + + const r1 = []; + r1.push(e.currentTurnParticipantId); // a acts + e = apply(e, nextTurn(e)); r1.push(e.currentTurnParticipantId); // b acts + + // b already acted. Deactivate b (NOT current now — b just became current + // via nextTurn, so b IS current). Toggle off advances pointer to c. + e = apply(e, toggleParticipantActive(e, 'b')); // b off + // current advanced to c (b was current) + r1.push(e.currentTurnParticipantId); // c "becomes" active turn + // reactivate b same round + e = apply(e, toggleParticipantActive(e, 'b')); // b on + + // nextTurn from c → round 2 (a). b must NOT re-act in round 1. + e = apply(e, nextTurn(e)); + expect(e.round).toBe(2); + expect(e.currentTurnParticipantId).toBe('a'); + + // b acted once in r1, must act once in r2 + e = apply(e, nextTurn(e)); + expect(e.currentTurnParticipantId).toBe('b'); + e = apply(e, nextTurn(e)); + expect(e.currentTurnParticipantId).toBe('c'); + + // per-round visit count + const bCountR1 = r1.filter(id => id === 'b').length; + expect(bCountR1).toBe(1); + }); + + test('deactivate+reactivate non-current who already acted: no re-act', () => { + // [a(10), b(7), c(3)]. a acts, b acts, c acts (round 1 done). + // Then deactivate a (already acted, not current since c is current). + // Reactivate a. a must not act again until round 2. + let e = enc([p('a',10),p('b',7),p('c',3)]); + e = apply(e, startEncounter(e)); // a + e = apply(e, nextTurn(e)); // b + e = apply(e, nextTurn(e)); // c (r1 full: a,b,c) + // c is current. deactivate a (not current, already acted). + e = apply(e, toggleParticipantActive(e, 'a')); // a off, pointer stays c + expect(e.currentTurnParticipantId).toBe('c'); + e = apply(e, toggleParticipantActive(e, 'a')); // a on + expect(e.currentTurnParticipantId).toBe('c'); + + // nextTurn from c → a (round 2). a acts in r2, once. + e = apply(e, nextTurn(e)); + expect(e.round).toBe(2); + expect(e.currentTurnParticipantId).toBe('a'); + }); +});