// Combat integrity test: replay exact op sequence through pure turn.js, // assert rotation + state invariants per round. This IS the test the audit // was supposed to be. Deterministic (seeded RNG). RED on current code = BUG-5. // // Mirrors scripts/replay-combat.js op order: // damage, heal (cleric), conditions, toggleActive, deathSave, // removeParticipant, addParticipant, updateParticipant, pause/resume, // reorderParticipants, revive-between-rounds. const shared = require('@ttrpg/shared'); const { makeParticipant, buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, togglePause, addParticipant, updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange, deathSave, toggleCondition, reorderParticipants, endEncounter, } = shared; // ---- seeded RNG (deterministic, reproducible) ---- let _seed = 12345; function rand() { // LCG _seed = (_seed * 1103515245 + 12345) & 0x7fffffff; return _seed / 0x7fffffff; } const rnd = (n) => Math.floor(rand() * n); const pick = (arr) => arr[rnd(arr.length)]; const CONDITIONS = [ 'alchemist_fire','bardic_inspiration','blinded','charmed','deafened', 'exhaustion','frightened','grappled','grazed','incapacitated', 'invisible','paralyzed','petrified','poisoned','prone','restrained', 'sapped','shield','slowed','stunned','unconscious','vexed', ]; function p(id, init, extra = {}) { return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 200, currentHp: 200, ...extra, }); } function setupEncounter() { 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, ]; // give deterministic ids to monsters for assertions const idMap = { Goblin1:'m1', Goblin2:'m2', OrcBoss:'m3', Wolf:'m4', Merchant:'n1' }; ps.forEach((part) => { if (idMap[part.name]) part.id = idMap[part.name]; }); return { name: 'combat-test', participants: ps, isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], }; } function currentParticipant(e) { if (!e.currentTurnParticipantId) return null; return (e.participants || []).find(x => x.id === e.currentTurnParticipantId) || null; } // Apply a result patch if present. function apply(e, result) { if (!result || !result.patch) return e; return { ...e, ...result.patch }; } describe('combat integrity (100 rounds, full op coverage)', () => { jest.setTimeout(30000); const ROUNDS = 100; const violations = []; test('every round visits each active participant exactly once', () => { _seed = 12345; // reset for reproducibility let e = setupEncounter(); e = apply(e, startEncounter(e)); let totalTurns = 0; let lastPaused = false; let lastReorder = 0; let reinforcementsAdded = 0; const condQueue = [...CONDITIONS]; for (let roundN = 1; roundN <= ROUNDS; roundN++) { const startRound = e.round; const seenThisRound = []; const cap = (e.participants.length + 2) * 2; let guard = 0; while (e.round === startRound && guard < cap) { // resume if paused (must precede nextTurn) if (lastPaused) { e = apply(e, togglePause(e)); lastPaused = false; } // advance let t; try { t = nextTurn(e); } catch (err) { violations.push({ round: roundN, type: 'nextTurn-throws', msg: err.message }); break; } e = apply(e, t); totalTurns++; // only count if turn belongs to THIS round (no wrap) if (e.round === startRound) seenThisRound.push(e.currentTurnParticipantId); const actor = currentParticipant(e); // 1. damage if (actor) { const foes = e.participants.filter( p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false ); if (foes.length > 0) { const tgt = pick(foes); const dmg = 1 + rnd(5); e = apply(e, applyHpChange(e, tgt.id, 'damage', dmg)); } } // 2. heal (cleric) if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) { const wounded = e.participants .filter(p => p.currentHp > 0 && p.currentHp < p.maxHp && p.isActive !== false) .sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp)); if (wounded.length > 0) { const tgt = wounded[0]; const amt = 2 + rnd(5); e = apply(e, applyHpChange(e, tgt.id, 'heal', amt)); } } // 3. conditions if (condQueue.length > 0) { const cond = condQueue[0]; const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); if (living.length > 0) { const tgt = pick(living); try { e = apply(e, toggleCondition(e, tgt.id, cond)); condQueue.shift(); } catch (err) { condQueue.shift(); } } } else if (totalTurns % 6 === 0) { const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); if (living.length > 0) { const tgt = pick(living); const cond = pick(CONDITIONS); try { e = apply(e, toggleCondition(e, tgt.id, cond)); } catch (err) {} } } // 4. toggleParticipantActive if (totalTurns % 9 === 0) { const living = e.participants.filter(p => p.currentHp > 0); if (living.length > 0) { const tgt = pick(living); try { e = apply(e, toggleParticipantActive(e, tgt.id)); } catch (err) {} } } // 5. deathSave if (actor && actor.currentHp <= 0 && !actor.isNpc) { try { e = apply(e, deathSave(e, actor.id, 1)); } catch (err) {} } // 6. removeParticipant if (totalTurns % 5 === 0) { const dead = e.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf')); if (dead) { try { e = apply(e, removeParticipant(e, dead.id)); } catch (err) {} } } // 7. addParticipant if (totalTurns % 10 === 0 && reinforcementsAdded < 4) { const spec = pick([ { name:`Reinforce${reinforcementsAdded+1}`, maxHp:120, initMod:1 }, { name:`Summon${reinforcementsAdded+1}`, maxHp:80, initMod:4 }, ]); const built = buildMonsterParticipant(spec).participant; try { e = apply(e, addParticipant(e, built)); reinforcementsAdded++; } catch (err) {} } // 8. updateParticipant if (totalTurns % 7 === 0) { const living = e.participants.filter(p => p.currentHp > 0); if (living.length > 0) { const tgt = pick(living); try { e = apply(e, updateParticipant(e, tgt.id, { notes:`edited@turn${totalTurns}` })); } catch (err) {} } } // 9. pause if (totalTurns % 12 === 0 && !lastPaused) { e = apply(e, togglePause(e)); lastPaused = true; } // 10. reorderParticipants (mirror replay's buggy signature usage — swallowed no-op) if (totalTurns % 8 === 0 && lastReorder !== totalTurns) { const living = e.participants.filter(p => p.currentHp > 0 && p.isActive !== false); if (living.length >= 2) { const tgt = living[0]; const newInit = (tgt.initiative || 0) + 1; try { const reordered = [...e.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p); e = apply(e, reorderParticipants(e, reordered)); lastReorder = totalTurns; } catch (err) {} } } guard++; if (!e.isStarted) break; } if (!e.isStarted) break; // === per-round invariants === const uniq = new Set(seenThisRound); if (uniq.size !== seenThisRound.length) { violations.push({ round: roundN, type: 'rotation-dupe', seen: seenThisRound.map(id => e.participants.find(p=>p.id===id)?.name||id) }); } // turnOrderIds no dup const orderUniq = new Set(e.turnOrderIds); if (orderUniq.size !== e.turnOrderIds.length) { violations.push({ round: roundN, type: 'turnOrder-dup-id', order: e.turnOrderIds }); } // currentTurn valid + active if (e.currentTurnParticipantId) { const ct = e.participants.find(p => p.id === e.currentTurnParticipantId); if (!ct) violations.push({ round: roundN, type: 'currentTurn-missing' }); else if (ct.isActive === false && e.isStarted) { violations.push({ round: roundN, type: 'currentTurn-inactive', id: ct.id }); } } // HP bounds for (const part of e.participants) { if (typeof part.currentHp !== 'number' || isNaN(part.currentHp) || part.currentHp < 0 || part.currentHp > part.maxHp) { violations.push({ round: roundN, type: 'hp-invalid', id: part.id, hp: part.currentHp, max: part.maxHp }); } } // revive dead between rounds const dead = e.participants.filter(p => p.currentHp <= 0 || p.isActive === false); for (const d of dead) { try { if (d.isActive === false) e = apply(e, toggleParticipantActive(e, d.id)); e = apply(e, applyHpChange(e, d.id, 'heal', d.maxHp)); } catch (err) {} } } // Report if (violations.length > 0) { const byType = {}; violations.forEach(v => { byType[v.type] = (byType[v.type]||0) + 1; }); const summary = Object.entries(byType).sort((a,b)=>b[1]-a[1]).map(([k,n])=>`${n}x ${k}`).join(', '); const first5 = violations.slice(0,5).map(v => `r${v.round} ${v.type}${v.seen?': '+JSON.stringify(v.seen):''}${v.order?': '+JSON.stringify(v.order):''}${v.msg?': '+v.msg:''}`).join('\n '); // dump full state for first dupe for triage throw new Error(`combat integrity violations: ${violations.length}\n ${summary}\n first 5:\n ${first5}`); } expect(violations).toHaveLength(0); }); });