diff --git a/TODO.md b/TODO.md index 2ce199d..3b54537 100644 --- a/TODO.md +++ b/TODO.md @@ -63,6 +63,22 @@ - 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) +- 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. +- 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). + ## 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/shared/tests/turn.combat.test.js b/shared/tests/turn.combat.test.js new file mode 100644 index 0000000..503f94f --- /dev/null +++ b/shared/tests/turn.combat.test.js @@ -0,0 +1,256 @@ +// 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++; + 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); + }); +}); diff --git a/tests/audit/audit-rotation.js b/tests/audit/audit-rotation.js index 856e4d0..b552550 100644 --- a/tests/audit/audit-rotation.js +++ b/tests/audit/audit-rotation.js @@ -1,4 +1,19 @@ -// scripts/audit-rotation.js +// DEPRECATED — DO NOT USE. +// Random simulation gave false 0-violations while replay (exact ops) +// reproduced real bugs. Replay-mirror approach = duplicate work. +// Kept for now in case parts reusable. Will delete once log analyzer +// (scratch/) + unit tests cover the ground. +// +// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer +// finds dupes/skips from real run. Unit tests lock confirmed bugs. +// +// To revive: delete this early-return block below. +if (require.main === module) { + console.error('audit-rotation.js DEPRECATED. See header comment.'); + process.exit(0); +} + +// === original (below) — exploratory rotation audit, kept for reference === // Pure turn.js simulation of replay op sequence. Detects first round where // rotation breaks (skip or dupe). Prints minimal repro + preceding ops. // No backend, no WS, no sleep. Fast. diff --git a/tests/audit/audit-state.js b/tests/audit/audit-state.js index 22e33ef..c1f1047 100644 --- a/tests/audit/audit-state.js +++ b/tests/audit/audit-state.js @@ -1,4 +1,19 @@ -// scripts/audit-state.js +// DEPRECATED — DO NOT USE. +// Random simulation gave false 0-violations while replay (exact ops) +// reproduced real bugs. Replay-mirror approach = duplicate work. +// Kept for now in case parts reusable. Will delete once log analyzer +// (scratch/) + unit tests cover the ground. +// +// Prefer: replay-combat.js dumps turnOrderIds per turn, log analyzer +// finds dupes/skips from real run. Unit tests lock confirmed bugs. +// +// To revive: delete this early-return block below. +if (require.main === module) { + console.error('audit-state.js DEPRECATED. See header comment.'); + process.exit(0); +} + +// === original (below) — exploratory bug-finder, kept for reference === // Expanded bug-finder: runs combat through pure turn.js, audits invariant // checks per round across multiple bug classes (not just rotation). // NOT a unit test (Math.random, exploratory). Unit tests lock known bugs. @@ -106,6 +121,18 @@ for (let roundN = 1; roundN <= ROUNDS; roundN++) { const dead = e.participants.find(p => p.currentHp <= 0); if (dead) { try { e = { ...e, ...removeParticipant(e, dead.id).patch }; } catch (err) {} } } + // mid-round revive: DM reactivates a downed participant's turn (mirrors + // replay-combat.js + real play). Triggers same path as revive-between-rounds + // but INSIDE rotation — where BUG-5 lives. + if (totalTurns % 7 === 0 && totalTurns > 0) { + const down = e.participants.find(p => p.currentHp <= 0 || p.isActive === false); + if (down) { + try { + if (down.isActive === false) e = { ...e, ...toggleParticipantActive(e, down.id).patch }; + e = { ...e, ...applyHpChange(e, down.id, 'heal', down.maxHp).patch }; + } catch (err) {} + } + } if (totalTurns % 10 === 0 && totalTurns > 0) { const np = makeParticipant({ id: `r${totalTurns}`, name:`R${totalTurns}`, type:'monster', initiative:9, maxHp:100, currentHp:100 }); try { e = { ...e, ...addParticipant(e, np).patch }; } catch (err) {}