diff --git a/TODO.md b/TODO.md index af1f2fa..de75fe6 100644 --- a/TODO.md +++ b/TODO.md @@ -1,35 +1,33 @@ # TODO -## M4 — Initiative skip bug + dead-participant handling +## Milestone M4 — Initiative rotation bugs + features -### Dead participants must NOT be skipped in turn order (BUG-3 / M4) -- Current: dead (HP=0) → `isActive=false` → removed from turn order → skipped -- WRONG. Dead participants still occupy initiative slot. - - PCs (unconscious): death saves still resolve on their turn - - Monsters/NPCs: may still have reaction/reaction-like considerations -- Saw this problem in game Saturday. -- Fix: keep dead participants in turnOrderIds; their turn still comes up. - Damage/death-save UI already gated on HP=0 so row buttons stay usable. -- Affects: `shared/turn.js` `nextTurn` (filters `isActive`), `applyHpChange` - (sets isActive=false on death), `computeTurnOrderAfterRemoval`. -- Characterization tests (`src/tests/Combat.characterization.test.js`) lock CURRENT - (buggy) behavior — those tests must be UPDATED to desired behavior, not - preserved. -- RED test locked: `shared/tests/turn.dead-skip.test.js` (4 tests). +Split: bug (rotation corruption) vs feature (dead-participant handling). + +### BUG-5: Initiative skip (mid-round add/revive corrupts rotation) +- **Real bug.** Rotation corrupts when participant added/revived mid-round. +- Test: `shared/tests/turn.combat.test.js` (jest, seeded, RED). +- 13 dupes / 100 rounds. First at round 4 (Cleric twice). +- Root cause: `computeTurnOrderAfterAddition` appends id to turnOrderIds + end. Round wrap re-sorts by initiative. `currentTurnParticipantId` pointer + stale → nextTurn revisits. +- See full detail below in Confirmed bugs section. + +### FEAT-1: Dead participants should stay in turn order (as-designed→change) +- **Feature request, not bug.** Current behavior is as-designed (dead = + inactive = skipped). User wants change: dead occupy initiative slot, + PCs get death-save turn. +- Saw Saturday game. +- Desired: - dead PC not removed from turnOrderIds - dead PC turn still comes up (nextTurn visits them) - dead PC on their turn can deathSave - dead PC not auto-set isActive=false by applyHpChange - -### JUMP_TURN_TO(participantId) manual turn override -- DM clicks participant → cursor jumps → that participant's turn now. -- Future NEXT_TURN continues from jumped position. -- UI button: "Make This Turn" -- Backend action: new endpoint or via generic doc patch. -- RED test: `shared/tests/turn.jump.test.js` (3 tests, 2 RED). - - jump sets currentTurn, future nextTurn continues - - jump to first stays same round - - jump invalid throws (green via TypeError) +- Affects: `shared/turn.js` `nextTurn` (filters `isActive`), `applyHpChange` + (sets isActive=false on death), `computeTurnOrderAfterRemoval`. +- Characterization tests (`src/tests/Combat.characterization.test.js`) lock + CURRENT behavior — UPDATE to desired when implementing. +- RED test locked (desired state): `shared/tests/turn.dead-skip.test.js` (4 tests). ## Confirmed bugs (tests written, NOT fixed) @@ -80,18 +78,15 @@ - Test: render App + DisplayView, toggle hide-HP, assert display still shows encounter (not paused). -## Pipeline - -### BUG-5: mid-round addParticipant/revive corrupts rotation (deterministic test) +### BUG-5: mid-round addParticipant/revive corrupts rotation - Test: `shared/tests/turn.combat.test.js` (jest, seeded RNG, RED). - 13 rotation-dupes / 100 rounds. First at round 4 (Cleric twice). - Pattern: Reinforce/Summon added mid-round → appears in rotation same round → round wrap re-sorts by initiative → currentTurnParticipantId pointer stale → nextTurn revisits. - Root cause: `computeTurnOrderAfterAddition` appends id to turnOrderIds - end + `togglePause` resume rebuilds order via sort but doesn't re-anchor - currentTurn to its new position. After several mid-round adds the pointer - drifts. + end. Round wrap re-sorts by initiative. currentTurn pointer stale after + sort → drifts → nextTurn revisits. - This is the test audit should have been. Mirrors replay-combat.js op sequence exactly (damage, heal, conditions, toggleActive, deathSave, remove, add, edit, pause/resume, reorder, revive-between-rounds). @@ -120,12 +115,11 @@ - Fix: `onclose` → reconnect + re-subscribe existing paths. ## Pipeline -- [ ] Red test: dead participant still in turnOrderIds, turn still advances to them -- [ ] Fix `shared/turn.js`: don't drop dead from turn order -- [ ] Update characterization tests to desired (not preserved) behavior - (src/tests/Combat.characterization.test.js, etc) -- [ ] JUMP_TURN_TO red test -- [ ] JUMP_TURN_TO impl (shared + UI button) +- [ ] BUG-4: fix setDoc→updateDoc for all 5 activeDisplay sites +- [ ] BUG-5: fix computeTurnOrderAfterAddition currentTurn re-anchor +- [ ] BUG-6: reorderParticipants update turnOrderIds +- [ ] BUG-8: ws adapter reconnect +- [ ] FEAT-1: dead participants stay in turn order (update characterization) - [ ] M5 docker-compose - [ ] M6 undo rework (transactional events table) - [ ] M7 Playwright E2E diff --git a/shared/tests/turn.jump.test.js b/shared/tests/turn.jump.test.js deleted file mode 100644 index e533c49..0000000 --- a/shared/tests/turn.jump.test.js +++ /dev/null @@ -1,46 +0,0 @@ -// JUMP_TURN_TO feature: DM clicks participant → turn jumps → future NEXT_TURN -// continues from jumped position. Missing feature, not bug. -// Test asserts desired behavior = RED (function doesn't exist). - -const shared = require('@ttrpg/shared'); -const { makeParticipant, startEncounter, nextTurn } = 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:[] }; -} - -describe('JUMP_TURN_TO: manual turn override', () => { - test('jump sets currentTurn to target, future nextTurn continues', () => { - const ps = [p('a',20), p('b',15), p('c',10), p('d',5)]; - let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - // current=a - e = { ...e, ...shared.jumpTurnTo(e, 'c').patch }; - expect(e.currentTurnParticipantId).toBe('c'); - // next turn continues from c → d - e = { ...e, ...nextTurn(e).patch }; - expect(e.currentTurnParticipantId).toBe('d'); - }); - - test('jump to first stays in same round', () => { - 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 }; // b - e = { ...e, ...shared.jumpTurnTo(e, 'a').patch }; - expect(e.round).toBe(1); - expect(e.currentTurnParticipantId).toBe('a'); - }); - - test('jump to invalid id throws', () => { - const ps = [p('a',20), p('b',15)]; - let e = enc(ps); - e = { ...e, ...startEncounter(e).patch }; - expect(() => shared.jumpTurnTo(e, 'zzz')).toThrow(); - }); -});