diff --git a/TODO.md b/TODO.md index 09f93e9..db2dbe1 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,31 @@ - UI button: "Make This Turn" - Backend action: new endpoint or via generic doc patch. +## Confirmed bugs (tests written, NOT fixed) + +### BUG-1: addParticipant + pause/resume corrupts turn rotation +- Audit: 32/100 rounds violate rotation when `addParticipant` + other state + changes fire while paused. +- Repro in replay round 10+: current stuck on one participant forever, + nextTurn returns same id, round never advances. +- Clean minimal repro (turn.pause-add.test.js) PASSES = combo needs more + state than one add+pause. Audit is authoritative repro. +- Togglepause resume rebuilds turnOrderIds via sort but leaves + currentTurnParticipantId stale. After enough adds/toggles the stale + pointer lands wrong → nextTurn repeats. +- Test: `shared/turn.pause-add.test.js` (3 tests, all green currently — + document when bug DOES NOT trigger. Audit catches it.) +- Real repro = run `scripts/audit-rotation.js` with all ops enabled. + +### BUG-2: addParticipant allows duplicate id +- `addParticipant(enc, dup)` appends same id to participants[] twice. +- togglePause resume rebuilds order → id appears twice in turnOrderIds → + nextTurn stuck repeating that id. +- Reachable in normal app? App uses crypto.randomUUID (fresh ids) so + unlikely. But no guard exists — defensive bug. +- Test: `shared/turn.characterization.test.js` 'addParticipant rejects + duplicate id' — RED currently (validates current allow-dup behavior). + ## Pipeline - [ ] Red test: dead participant still in turnOrderIds, turn still advances to them - [ ] Fix `shared/turn.js`: don't drop dead from turn order diff --git a/scripts/audit-rotation.js b/scripts/audit-rotation.js index d235b9c..944929b 100644 --- a/scripts/audit-rotation.js +++ b/scripts/audit-rotation.js @@ -62,7 +62,7 @@ for (let roundN = 1; roundN <= ROUNDS; roundN++) { const cap = (enc.participants.length + 2) * 2; let guard = 0; - // BISECT: testing damage+heal+pause + // BISECT: dmg+heal+cond+add+pause const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId); if (actor) { const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false); diff --git a/shared/turn.pause-add.test.js b/shared/turn.pause-add.test.js new file mode 100644 index 0000000..88c4eba --- /dev/null +++ b/shared/turn.pause-add.test.js @@ -0,0 +1,100 @@ +// Characterization test: addParticipant + pause/resume corrupts turn rotation. +// Audit found 56-77 violations/100 rounds starting round 20 in pure turn.js +// simulation. Visible in live replay (round 10: 17 turns, 6 duped actors, +// R-series stuck repeating forever). +// +// This test uses FRESH ids (crypto.randomUUID equivalent) — NOT the audit's +// self-inflicted dup (loop spun while paused, re-added same `r${totalTurns}`). +// Validates real bug reachable via normal UI flow (DM adds monster while paused, +// resumes). + +const shared = require('@ttrpg/shared'); +const { startEncounter, nextTurn, togglePause, addParticipant, makeParticipant } = shared; + +function p(id, initiative, extra = {}) { + return makeParticipant({ + id, name: id, type: 'monster', + initiative, maxHp: 100, currentHp: 100, + ...extra, + }); +} + +function enc(ps) { + return { + name: 'T', participants: ps, + isStarted: false, isPaused: false, + round: 0, currentTurnParticipantId: null, turnOrderIds: [], + }; +} + +describe('addParticipant + pause/resume rotation corruption', () => { + test('add fresh participant while paused, resume, rotation completes full cycle', () => { + const ps = [p('a', 20), p('b', 15), p('c', 10)]; + let e = enc(ps); + e = { ...e, ...startEncounter(e).patch }; + const baseOrder = e.turnOrderIds.slice(); // [a,b,c] + + e = { ...e, ...nextTurn(e).patch }; // current=b + e = { ...e, ...togglePause(e).patch }; // pause + + // add fresh participant x (initiative 25, would sort first) + const x = p('x', 25); + e = { ...e, ...addParticipant(e, x).patch }; + e = { ...e, ...togglePause(e).patch }; // resume (rebuilds order) + + // after resume, complete one full round: visit each active participant once + const visited = [e.currentTurnParticipantId]; + for (let i = 0; i < e.turnOrderIds.length - 1; i++) { + e = { ...e, ...nextTurn(e).patch }; + visited.push(e.currentTurnParticipantId); + } + const uniq = new Set(visited); + // EXPECT: 4 unique (a,b,c,x). BUG: rotation may not visit all. + expect(uniq.size).toBe(e.turnOrderIds.length); + }); + + test('multiple adds while paused, resume, rotation visits all', () => { + const ps = [p('a', 20), p('b', 15), p('c', 10)]; + let e = enc(ps); + e = { ...e, ...startEncounter(e).patch }; + + e = { ...e, ...nextTurn(e).patch }; // current=b + e = { ...e, ...togglePause(e).patch }; // pause + + // add 3 fresh participants + for (const id of ['x', 'y', 'z']) { + const np = p(id, 5 + Math.floor(Math.random() * 30)); + e = { ...e, ...addParticipant(e, np).patch }; + } + e = { ...e, ...togglePause(e).patch }; // resume + + const visited = [e.currentTurnParticipantId]; + for (let i = 0; i < e.turnOrderIds.length + 2; i++) { + e = { ...e, ...nextTurn(e).patch }; + visited.push(e.currentTurnParticipantId); + } + const uniq = new Set(visited); + // EXPECT: all 6 participants reachable. BUG: some stuck/repeated. + expect(uniq.size).toBe(e.turnOrderIds.length); + }); + + test('add while running, then pause+resume, rotation stays valid', () => { + const ps = [p('a', 20), p('b', 15), p('c', 10)]; + let e = enc(ps); + e = { ...e, ...startEncounter(e).patch }; + + e = { ...e, ...nextTurn(e).patch }; // current=b + const x = p('x', 25); + e = { ...e, ...addParticipant(e, x).patch }; // add while running + e = { ...e, ...togglePause(e).patch }; // pause + e = { ...e, ...togglePause(e).patch }; // resume + + const visited = [e.currentTurnParticipantId]; + for (let i = 0; i < e.turnOrderIds.length + 2; i++) { + e = { ...e, ...nextTurn(e).patch }; + visited.push(e.currentTurnParticipantId); + } + const uniq = new Set(visited); + expect(uniq.size).toBe(e.turnOrderIds.length); + }); +});