// Invariant: no real skip. Every active participant at round start (still // active at round end) gets a turn. Tracks per ACTUAL round (e.round), so // rounds spanning pause/resume across loop iterations count correctly. // // Guards BUG-5 fix (slot-array turn order, no re-sort on wrap/resume). // If this goes RED, turn order rotation is skipping participants again. // // New API: mutating funcs are async, take ctx last, write encounter + log // internally, return newEnc. setup(ctx) returns the started encounter; // loop bodies await each mutating call. 'use strict'; const shared = require('@ttrpg/shared'); const { buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, removeParticipant, toggleParticipantActive, } = shared; const { mockCtx } = require('./_helpers'); const nm = (enc) => (id) => { const f = enc.participants.find(p => p.id === id); return f ? f.name : id; }; function setup(ctx) { const ps = [ buildCharacterParticipant({ id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }).participant, buildCharacterParticipant({ id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }).participant, buildCharacterParticipant({ id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }).participant, buildMonsterParticipant({ name: 'Goblin1', maxHp: 100, initMod: 2 }).participant, buildMonsterParticipant({ name: 'Goblin2', maxHp: 100, initMod: 2 }).participant, buildMonsterParticipant({ name: 'OrcBoss', maxHp: 500, initMod: 1 }).participant, buildMonsterParticipant({ name: 'Wolf', maxHp: 120, initMod: 3 }).participant, buildMonsterParticipant({ name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }).participant, ]; let e = { name: 't', participants: ps, isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], }; return startEncounter(e, ctx); // async → Promise } describe('BUG-5: turn-order rotation never skips (deterministic)', () => { jest.setTimeout(15000); test('pure nextTurn: 0 skips across 100 rounds', async () => { const { ctx } = mockCtx(); let e = await setup(ctx); let totalSkips = 0; for (let roundN = 1; roundN <= 100; roundN++) { const startRound = e.round; const activeAtStart = new Set(e.participants.filter(p => p.isActive).map(p => p.id)); const acted = new Set(); acted.add(e.currentTurnParticipantId); let guard = 0; const cap = e.participants.length + 1; while (e.round === startRound && guard < cap) { e = await nextTurn(e, ctx); if (e.round === startRound) acted.add(e.currentTurnParticipantId); guard++; } const skipped = [...activeAtStart].filter(id => { const p = e.participants.find(x => x.id === id); return p && p.isActive && !acted.has(id); }); totalSkips += skipped.length; } expect(totalSkips).toBe(0); }); test('with pause/resume + add/remove/toggle: 0 skips across ~540 rounds', async () => { const { ctx } = mockCtx(); let e = await setup(ctx); const N = nm(e); let curRound = null; let activeAtRoundStart = new Set(); let actedThisRound = new Set(); const onRoundStart = (enc) => { curRound = enc.round; activeAtRoundStart = new Set(enc.participants.filter(p => p.isActive).map(p => p.id)); actedThisRound = new Set(); if (enc.currentTurnParticipantId) actedThisRound.add(enc.currentTurnParticipantId); }; onRoundStart(e); let totalRealSkips = 0; let added = 0; let turns = 0; const MAX_TURNS = 2000; while (turns < MAX_TURNS && e.isStarted) { turns++; if (e.isPaused) e = await togglePause(e, ctx); if (turns % 7 === 0 && !e.isPaused) { e = await togglePause(e, ctx); continue; } const prevRound = e.round; e = await nextTurn(e, ctx); if (e.round !== prevRound) { const skipped = [...activeAtRoundStart].filter(id => { const p = e.participants.find(x => x.id === id); return p && p.isActive && !actedThisRound.has(id); }); totalRealSkips += skipped.length; onRoundStart(e); } else { actedThisRound.add(e.currentTurnParticipantId); } if (turns % 9 === 0 && added < 8) { const b = buildMonsterParticipant({ name: `R${added + 1}`, maxHp: 120, initMod: 3 }).participant; b.id = `reinforce${added + 1}`; e = await addParticipant(e, b, ctx); added++; } if (turns % 13 === 0) { const cand = e.participants.filter(p => p.type === 'monster' && p.isActive && p.id !== e.currentTurnParticipantId); if (cand.length) e = await removeParticipant(e, cand[0].id, ctx); } if (turns % 17 === 0) { const cand = e.participants.filter(p => p.isActive && p.id !== e.currentTurnParticipantId); if (cand.length) { const t = cand[0]; e = await toggleParticipantActive(e, t.id, ctx); e = await toggleParticipantActive(e, t.id, ctx); } } } expect(totalRealSkips).toBe(0); }); });