// Regression test: full round must rotate through ALL active participants exactly once. // Audit of 100-round replay found 124 skips + 78 dupes (round 1 already missing Fighter // before any coverage action). nextTurn has core bug, not just coverage-path issue. // // This test is RED until nextTurn fixed. const shared = require('@ttrpg/shared'); const { startEncounter, nextTurn, makeParticipant } = shared; function p(id, initiative, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', initiative, maxHp: 20, currentHp: 20, ...extra, }); } function enc(ps) { return { name: 'T', participants: ps, isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], }; } describe('round rotation integrity', () => { test('3 participants: one full round visits each exactly once', () => { const ps = [p('a', 20), p('b', 15), p('c', 10)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; const startOrder = e.turnOrderIds.slice(); const visited = [e.currentTurnParticipantId]; // advance (len-1) turns: visits remaining participants, round NOT yet wrapped. for (let i = 0; i < startOrder.length - 1; i++) { e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); } expect(e.round).toBe(1); // still round 1 const uniq = new Set(visited); expect(uniq.size).toBe(startOrder.length); // each exactly once expect(visited.length).toBe(startOrder.length); }); test('8 participants (replay shape): one full round visits each exactly once', () => { const ps = [ p('Goblin1', 12), p('Wolf', 13), p('Merchant', 8), p('OrcBoss', 11), p('Goblin2', 12), p('Fighter', 14), p('Rogue', 15), p('Cleric', 10), ]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; const startOrder = e.turnOrderIds.slice(); const visited = [e.currentTurnParticipantId]; for (let i = 0; i < startOrder.length - 1; i++) { e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); } expect(e.round).toBe(1); const uniq = new Set(visited); expect(uniq.size).toBe(startOrder.length); expect(visited.length).toBe(startOrder.length); }); test('multiple rounds: each round visits each participant exactly once', () => { const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; const startOrder = e.turnOrderIds.slice(); const expectedRound = e.round; // capture exactly one full round (current + len-1 advances), no wrap yet. const visited = [e.currentTurnParticipantId]; for (let i = 0; i < startOrder.length - 1; i++) { e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); } const uniq = new Set(visited); expect(uniq.size).toBe(startOrder.length); expect(e.round).toBe(expectedRound); }); }); describe('round rotation with mid-round state changes', () => { const { toggleParticipantActive, addParticipant, removeParticipant, reorderParticipants, applyHpChange } = shared; test('toggle a participant inactive mid-round, others still each visited once', () => { const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; const startOrder = e.turnOrderIds.slice(); const visited = [e.currentTurnParticipantId]; e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); // now mark 'a' inactive (already took its turn) e = { ...e, ...toggleParticipantActive(e, 'a').patch }; e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); // round should wrap, but 'a' inactive so only b,c,d visited const visitedActive = visited.filter(id => id !== 'a'); const uniq = new Set(visitedActive); expect(uniq.size).toBe(startOrder.length - 1); // b,c,d each once }); test('reactivate inactive participant mid-round, it gets a turn this round', () => { const ps = [p('a', 20), p('b', 15), p('c', 10), p('d', 5)]; let e = enc(ps); // start with 'c' inactive e.participants = e.participants.map(p => p.id === 'c' ? { ...p, isActive: false } : p); e = { ...e, ...startEncounter(e).patch }; const startOrder = e.turnOrderIds.slice(); // should be a,b,d (c excluded) expect(startOrder).not.toContain('c'); // advance one turn, then reactivate c e = { ...e, ...nextTurn(e).patch }; e = { ...e, ...toggleParticipantActive(e, 'c').patch }; // continue rotation - c should now be reachable const visited = [startOrder[0], e.currentTurnParticipantId]; for (let i = 0; i < startOrder.length; i++) { e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); } expect(visited).toContain('c'); }); test('addParticipant mid-round: new participant gets turn this round or next', () => { const ps = [p('a', 20), p('b', 15), p('c', 10)]; let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; const startOrder = e.turnOrderIds.slice(); e = { ...e, ...nextTurn(e).patch }; // advance one // add new participant const newP = p('x', 25); e = { ...e, ...addParticipant(e, newP).patch }; // finish round - original 3 should still each get exactly one turn const visited = [startOrder[0], e.currentTurnParticipantId]; while (e.round === 1) { const r = nextTurn(e); e = { ...e, ...r.patch }; visited.push(e.currentTurnParticipantId); if (visited.length > 20) break; // safety } const originals = visited.filter(id => ['a','b','c'].includes(id)); const uniq = new Set(originals); expect(uniq.size).toBe(3); }); test('reorderParticipants mid-round keeps rotation valid', () => { const ps = [p('a', 20), p('b', 15), p('c', 15), p('d', 5)]; // b,c same init (15) let e = enc(ps); e = { ...e, ...startEncounter(e).patch }; const startOrder = e.turnOrderIds.slice(); e = { ...e, ...nextTurn(e).patch }; // reorder: swap b,c (same initiative) e = { ...e, ...reorderParticipants(e, 'b', 'c').patch }; const visited = [startOrder[0], e.currentTurnParticipantId]; for (let i = 0; i < startOrder.length; i++) { e = { ...e, ...nextTurn(e).patch }; visited.push(e.currentTurnParticipantId); } const uniq = new Set(visited); expect(uniq.size).toBeGreaterThanOrEqual(startOrder.length); }); });