From 13490fe3deab171c78296563d2083da7b9db8f77 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:49:39 -0400 Subject: [PATCH] tests: round-rotation audit, dup-id fail, replay rewrite - turn.round-rotation.test.js: 7 tests, full round visits each active participant once (pure nextTurn clean). Green. - turn.characterization.test.js: RED 'addParticipant rejects duplicate id'. Validates current behavior allows dup ids (self-inflicted in audit via loop spin-while-paused re-adding same id; unreachable in app via crypto.randomUUID, but documents gap). - audit-rotation.js: pure turn.js simulation of replay op sequence. Detects rotation violations (skip/dupe per round). Pause disabled = 0 violations across 100 rounds. Pause enabled = 56-77 violations starting round 20. Pinpoints addParticipant+pause interaction. - repro-pause-bug.js: minimal repro scripts. - replay-combat.js: rewritten for real rounds (full initiative cycles), visible damage each turn, all conditions, toggleActive, remove, reinforce, edit, pause/resume, reorder, endEncounter. HP bumped for 100-round sustain + revive dead each round. No feature code changed. --- scripts/audit-rotation.js | 152 ++++++++++++++ scripts/replay-combat.js | 290 +++++++++++++++++++++------ scripts/repro-pause-bug.js | 76 +++++++ shared/turn.characterization.test.js | 10 + shared/turn.round-rotation.test.js | 175 ++++++++++++++++ 5 files changed, 643 insertions(+), 60 deletions(-) create mode 100644 scripts/audit-rotation.js create mode 100644 scripts/repro-pause-bug.js create mode 100644 shared/turn.round-rotation.test.js diff --git a/scripts/audit-rotation.js b/scripts/audit-rotation.js new file mode 100644 index 0000000..d235b9c --- /dev/null +++ b/scripts/audit-rotation.js @@ -0,0 +1,152 @@ +// scripts/audit-rotation.js +// 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. + +const shared = require('../shared'); +const { + buildCharacterParticipant, buildMonsterParticipant, + startEncounter, nextTurn, togglePause, + addParticipant, updateParticipant, removeParticipant, + toggleParticipantActive, applyHpChange, deathSave, + toggleCondition, reorderParticipants, endEncounter, +} = shared; + +function makeParticipant(opts) { return shared.makeParticipant(opts); } + +const ps = [ + makeParticipant({ id: 'c1', name: 'Fighter', type: 'character', initiative: 14, maxHp: 200, currentHp: 200 }), + makeParticipant({ id: 'c2', name: 'Cleric', type: 'character', initiative: 10, maxHp: 180, currentHp: 180 }), + makeParticipant({ id: 'c3', name: 'Rogue', type: 'character', initiative: 15, maxHp: 160, currentHp: 160 }), + makeParticipant({ id: 'm1', name: 'Goblin1', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }), + makeParticipant({ id: 'm2', name: 'Goblin2', type: 'monster', initiative: 12, maxHp: 100, currentHp: 100 }), + makeParticipant({ id: 'm3', name: 'OrcBoss', type: 'monster', initiative: 11, maxHp: 500, currentHp: 500 }), + makeParticipant({ id: 'm4', name: 'Wolf', type: 'monster', initiative: 13, maxHp: 120, currentHp: 120 }), + makeParticipant({ id: 'n1', name: 'Merchant', type: 'monster', initiative: 8, maxHp: 150, currentHp: 150, isNpc: true }), +]; + +let enc = { + name: 'audit', participants: ps, + isStarted: false, isPaused: false, + round: 0, currentTurnParticipantId: null, turnOrderIds: [], +}; + +const opLog = []; +function log(label) { opLog.push({ round: enc.round, turn: currentName(enc), label }); } + +function apply(result, label) { + if (!result || !result.patch) return; + enc = { ...enc, ...result.patch }; + log(label); +} + +function currentName(e) { + if (!e.currentTurnParticipantId) return '(none)'; + const p = e.participants.find(x => x.id === e.currentTurnParticipantId); + return p ? p.name : '(missing)'; +} + +// start +apply(startEncounter(enc), 'startEncounter'); +console.log(`start: order=${enc.turnOrderIds.join(',')} first=${currentName(enc)}`); + +const ROUNDS = 100; +let totalTurns = 0; +let violations = []; + +for (let roundN = 1; roundN <= ROUNDS; roundN++) { + const startRound = enc.round; + const seenThisRound = []; + // record starting turn (already current at top of round) + seenThisRound.push(enc.currentTurnParticipantId); + const cap = (enc.participants.length + 2) * 2; + let guard = 0; + + // BISECT: testing damage+heal+pause + const actor = enc.participants.find(p => p.id === enc.currentTurnParticipantId); + if (actor) { + const foes = enc.participants.filter(p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false); + if (foes.length > 0) { + const tgt = foes[Math.floor(Math.random() * foes.length)]; + const dmg = 1 + Math.floor(Math.random() * 5); + apply(applyHpChange(enc, tgt.id, 'damage', dmg), `damage ${actor.name}→${tgt.name} -${dmg}`); + } + } + if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) { + const wounded = enc.participants.filter(p => p.currentHp > 0 && p.currentHp < p.maxHp).sort((a,b)=>(a.currentHp/a.maxHp)-(b.currentHp/b.maxHp)); + if (wounded.length > 0) { + const tgt = wounded[0]; const amt = 2 + Math.floor(Math.random()*5); + apply(applyHpChange(enc, tgt.id, 'heal', amt), `heal ${tgt.name} +${amt}`); + } + } + if (totalTurns % 4 === 0) { + const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); + if (living.length > 0) { + const tgt = living[Math.floor(Math.random()*living.length)]; + apply(toggleCondition(enc, tgt.id, 'stunned'), `condition stunned on ${tgt.name}`); + } + } + if (totalTurns % 9 === 0 && totalTurns > 0) { + const living = enc.participants.filter(p => p.currentHp > 0); + if (living.length > 0) { + const tgt = living[Math.floor(Math.random()*living.length)]; + apply(toggleParticipantActive(enc, tgt.id), `toggleActive ${tgt.name}`); + } + } + if (totalTurns % 5 === 0 && totalTurns > 0) { + const dead = enc.participants.find(p => p.currentHp <= 0); + if (dead) apply(removeParticipant(enc, dead.id), `remove ${dead.name}`); + } + if (totalTurns % 10 === 0 && totalTurns > 0) { + const newP = makeParticipant({ id: `r${totalTurns}`, name: `R${totalTurns}`, type: 'monster', initiative: 9, maxHp: 100, currentHp: 100 }); + apply(addParticipant(enc, newP), `add ${newP.name}`); + } + //REMOVED + //REMOVED + // 9. pause — re-enabled, isolating interaction + if (totalTurns % 12 === 0 && totalTurns > 0) { + apply(togglePause(enc), 'pause'); + } + + while (enc.round === startRound && guard < cap) { + // advance FIRST, then check wrap before recording + let t; + try { t = nextTurn(enc); } catch (e) { log(`nextTurn ERR: ${e.message}`); break; } + apply(t, 'nextTurn'); + // stop at round wrap — nextTurn just rolled into new round + if (enc.round !== startRound) break; + totalTurns++; + seenThisRound.push(enc.currentTurnParticipantId); + guard++; + if (!enc.isStarted) break; + } + + // audit this round + const uniq = new Set(seenThisRound); + const dupes = seenThisRound.filter(id => seenThisRound.indexOf(id) !== seenThisRound.lastIndexOf(id)); + if (dupes.length > 0 || uniq.size < seenThisRound.length) { + violations.push({ round: roundN, seen: seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id), dupes }); + if (violations.length <= 3) { + console.log(`\n=== VIOLATION round ${roundN} ===`); + console.log(` seen: ${seenThisRound.map(id => enc.participants.find(p=>p.id===id)?.name||id).join(' → ')}`); + console.log(` dupes: ${[...new Set(dupes)].map(id => enc.participants.find(p=>p.id===id)?.name||id).join(', ')}`); + // print op log for this round + const roundOps = opLog.filter(o => o.round === startRound || o.round === roundN); + console.log(` ops: ${roundOps.map(o => o.label).join(' | ')}`); + } + } + + if (!enc.isStarted) { console.log('encounter ended'); break; } + + // revive dead + const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false); + for (const d of dead) { + if (d.isActive === false) apply(toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`); + apply(applyHpChange(enc, d.id, 'heal', d.maxHp), `revive-heal ${d.name} →${d.maxHp}`); + } +} + +console.log(`\ntotal violations: ${violations.length} / ${ROUNDS} rounds`); +if (violations.length > 0) { + console.log('first 5:', violations.slice(0,5).map(v => `r${v.round}`)); +} diff --git a/scripts/replay-combat.js b/scripts/replay-combat.js index e92c8af..cbe20f7 100644 --- a/scripts/replay-combat.js +++ b/scripts/replay-combat.js @@ -1,24 +1,40 @@ // scripts/replay-combat.js -// Drive a full combat through the LIVE backend (generic KV REST) so the player -// display window (subscribed via WS) live-updates as combat progresses. +// Drive a full combat through the LIVE backend via the ws storage adapter +// (same contract boundary as the App), so the player display window +// (subscribed via WS) live-updates as combat progresses. // Uses shared/turn.js for all turn logic (same model as the UI). // +// Coverage goals (rotate across rounds): +// - nextTurn (every turn) +// - applyHpChange damage + heal (varying magnitude) +// - toggleCondition (all CONDITIONS at least once) +// - toggleParticipantActive (mark inactive, later reactivate) +// - deathSave (when a PC reaches 0 HP) +// - addParticipant (reinforcements drop in) +// - removeParticipant (dead monsters hauled off) +// - updateParticipant (edit fields mid-combat) +// - togglePause / resume +// - reorderParticipants (initiative reorder) +// - endEncounter (cleanup) +// // Run: node scripts/replay-combat.js [rounds] [delayMs] -// rounds default 100, delayMs default 800 +// rounds default 100, delayMs default 200 'use strict'; const shared = require('../shared'); const { buildCharacterParticipant, buildMonsterParticipant, - startEncounter, nextTurn, applyHpChange, toggleCondition, - endEncounter, + startEncounter, nextTurn, togglePause, + addParticipant, updateParticipant, removeParticipant, + toggleParticipantActive, applyHpChange, deathSave, + toggleCondition, reorderParticipants, endEncounter, } = shared; const { createWsStorage } = require('../src/storage/ws'); const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001'; const WS_URL = process.env.BACKEND_WS || BACKEND.replace(/^http/, 'ws') + '/ws'; -const ROUNDS = parseInt(process.argv[2], 10) || 10; +const ROUNDS = parseInt(process.argv[2], 10) || 100; const DELAY = parseInt(process.argv[3], 10) || 200; const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; @@ -34,15 +50,29 @@ const getPath = { const sleep = (ms) => new Promise(r => setTimeout(r, ms)); -// Use the ADAPTER as the contract boundary (same as App). No raw REST, no -// hand-built paths — adapter normalizes internally. Catches path-shape drift -// that the earlier raw-REST replay caused. +// Use the ADAPTER as the contract boundary (same as App). No raw REST. const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL }); +// Mirror App.js CONDITIONS so we exercise all of them. +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', +]; + +async function patch(encounterPath, enc, result, label) { + if (!result || !result.patch) { if (label) console.log(` (${label}: no-op)`); return enc; } + await storage.updateDoc(encounterPath, result.patch); + if (label) console.log(` [${label}]`); + return { ...enc, ...result.patch }; +} + +function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } + async function main() { console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`); - // campaign + encounter. Adapter takes firebase-prefixed paths (same as App). const campaignId = crypto.randomUUID(); const encounterId = crypto.randomUUID(); @@ -52,28 +82,27 @@ async function main() { ownerId: 'replay', createdAt: new Date().toISOString(), players: [ - { id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2 }, - { id: 'c2', name: 'Cleric', defaultMaxHp: 24, defaultInitMod: 1 }, - { id: 'c3', name: 'Rogue', defaultMaxHp: 22, defaultInitMod: 3 }, + { id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }, + { id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }, + { id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }, ], }); - // build participants (roll initiative via shared) - const chars = [ - { id: 'c1', name: 'Fighter', defaultMaxHp: 30, defaultInitMod: 2 }, - { id: 'c2', name: 'Cleric', defaultMaxHp: 24, defaultInitMod: 1 }, - { id: 'c3', name: 'Rogue', defaultMaxHp: 22, defaultInitMod: 3 }, + const charSpecs = [ + { id: 'c1', name: 'Fighter', defaultMaxHp: 200, defaultInitMod: 2 }, + { id: 'c2', name: 'Cleric', defaultMaxHp: 180, defaultInitMod: 1 }, + { id: 'c3', name: 'Rogue', defaultMaxHp: 160, defaultInitMod: 3 }, ]; const monsterSpecs = [ - { name: 'Goblin1', maxHp: 8, initMod: 2 }, - { name: 'Goblin2', maxHp: 8, initMod: 2 }, - { name: 'OrcBoss', maxHp: 60, initMod: 1 }, - { name: 'Wolf', maxHp: 14, initMod: 3 }, - { name: 'Merchant', maxHp: 12, initMod: 0, isNpc: true }, + { name: 'Goblin1', maxHp: 100, initMod: 2 }, + { name: 'Goblin2', maxHp: 100, initMod: 2 }, + { name: 'OrcBoss', maxHp: 500, initMod: 1 }, + { name: 'Wolf', maxHp: 120, initMod: 3 }, + { name: 'Merchant', maxHp: 150, initMod: 0, isNpc: true }, ]; const participants = [ - ...chars.map(c => buildCharacterParticipant(c).participant), + ...charSpecs.map(c => buildCharacterParticipant(c).participant), ...monsterSpecs.map(m => buildMonsterParticipant(m).participant), ]; @@ -91,86 +120,227 @@ async function main() { console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`); - // point active display so player view shows it await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounterId, hidePlayerHp: false, }); - await sleep(1000); + await sleep(800); const encounterPath = getPath.encounter(campaignId, encounterId); const activeDisplayPath = getPath.activeDisplay(); // start let enc = await storage.getDoc(encounterPath); - const start = startEncounter(enc); - await storage.updateDoc(encounterPath, start.patch); - enc = { ...enc, ...start.patch }; + enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter'); console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`); await sleep(DELAY); - // main loop: ROUNDS = full initiative cycles (each round = all participants act). - const DAMAGERS = ['Fighter', 'Cleric', 'Rogue', 'Wolf', 'Goblin1', 'Goblin2', 'OrcBoss']; - const TARGETS = ['Goblin1', 'Goblin2', 'Wolf', 'OrcBoss', 'Fighter', 'Cleric', 'Rogue', 'Merchant']; - let turnInRound = 0; - let prevRound = enc.round; let totalTurns = 0; + const condQueue = [...CONDITIONS].sort(() => Math.random() - 0.5); + let reinforcementsAdded = 0; + let lastPaused = false; + let lastReorder = 0; for (let roundN = 1; roundN <= ROUNDS; roundN++) { // advance initiative until round counter ticks (full cycle done). - const cap = (enc.participants.length + 1) * 2; + const cap = (enc.participants.length + 2) * 2; let guard = 0; while (enc.round < roundN + 1 && guard < cap) { enc = await storage.getDoc(encounterPath); - const t = nextTurn(enc); - await storage.updateDoc(encounterPath, t.patch); - enc = { ...enc, ...t.patch }; - totalTurns++; - turnInRound++; - // visible action: current turn actor damages a random living target. + // 9. resume if paused: must happen BEFORE nextTurn or it throws. + if (lastPaused) { + enc = await patch(encounterPath, enc, togglePause(enc), 'resume'); + lastPaused = false; + } + + let t; + try { t = nextTurn(enc); } catch (e) { console.log(` nextTurn err: ${e.message}`); break; } + enc = await patch(encounterPath, enc, t, null); + totalTurns++; const actorName = firstActiveName(enc); - const livingTargets = enc.participants.filter( - p => p.currentHp > 0 && p.name !== actorName && TARGETS.includes(p.name) - ); - if (livingTargets.length > 0 && DAMAGERS.includes(actorName)) { - const tgt = livingTargets[Math.floor(Math.random() * livingTargets.length)]; - const dmg = 3 + Math.floor(Math.random() * 8); // 3-10 - const h = applyHpChange(enc, tgt.id, 'damage', dmg); - if (h.patch) { - await storage.updateDoc(encounterPath, h.patch); - enc = { ...enc, ...h.patch }; - console.log(` ${actorName} → ${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`); + const actor = currentParticipant(enc); + + // 1. damage: actor hits a random living, active target. + if (actor) { + const foes = enc.participants.filter( + p => p.id !== actor.id && p.currentHp > 0 && p.isActive !== false && !p.name.startsWith('Dead') + ); + if (foes.length > 0) { + const tgt = pick(foes); + const dmg = 1 + Math.floor(Math.random() * 5); // 1-5 + const h = applyHpChange(enc, tgt.id, 'damage', dmg); + if (h.patch) { + await storage.updateDoc(encounterPath, h.patch); + enc = { ...enc, ...h.patch }; + console.log(` ${actorName} → ${tgt.name} (-${dmg}, hp=${tgt.currentHp - dmg})`); + } } } - console.log(` turn ${turnInRound} (round ${enc.round}): ${actorName}`); + // 2. heal: Cleric (when active) heals lowest-HP ally every other turn. + if (actor && actor.name === 'Cleric' && totalTurns % 2 === 0) { + const wounded = enc.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 + Math.floor(Math.random() * 5); // 2-6 + const h = applyHpChange(enc, tgt.id, 'heal', amt); + if (h.patch) { + await storage.updateDoc(encounterPath, h.patch); + enc = { ...enc, ...h.patch }; + console.log(` Cleric heal → ${tgt.name} (+${amt}, hp=${tgt.currentHp + amt})`); + } + } + } + + // 3. conditions: toggle a queued condition off some participant each turn. + if (condQueue.length > 0) { + const cond = condQueue[0]; + const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); + if (living.length > 0) { + const tgt = pick(living); + try { + const c = toggleCondition(enc, tgt.id, cond); + enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`); + condQueue.shift(); + } catch (e) { console.log(` condition ${cond} err: ${e.message}`); condQueue.shift(); } + } + } else if (totalTurns % 6 === 0) { + // second pass: toggle a random condition on random participant (add/remove). + const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); + if (living.length > 0) { + const tgt = pick(living); + const cond = pick(CONDITIONS); + try { + const c = toggleCondition(enc, tgt.id, cond); + enc = await patch(encounterPath, enc, c, `condition ${cond} on ${tgt.name}`); + } catch (e) { /* ignore */ } + } + } + + // 4. toggleParticipantActive: randomly mark someone inactive, or reactivate. + if (totalTurns % 9 === 0) { + const living = enc.participants.filter(p => p.currentHp > 0); + if (living.length > 0) { + const tgt = pick(living); + try { + const r = toggleParticipantActive(enc, tgt.id); + enc = await patch(encounterPath, enc, r, `${tgt.isActive === false ? 'reactivate' : 'deactivate'} ${tgt.name}`); + } catch (e) { /* ignore */ } + } + } + + // 5. deathSave: when a PC is at 0 HP on their turn, attempt a save. + if (actor && actor.currentHp <= 0 && !actor.isNpc && actor.name !== actor.name.startsWith('Monster')) { + try { + const ds = deathSave(enc, actor.id, 1); + enc = await patch(encounterPath, enc, ds, `deathSave ${actor.name} (+1 success)`); + } catch (e) { /* ignore */ } + } + + // 6. removeParticipant: dead monsters hauled off (every ~5 turns). + if (totalTurns % 5 === 0) { + const dead = enc.participants.find(p => (p.isDying || p.currentHp <= 0) && (p.isNpc || p.name.startsWith('Goblin') || p.name === 'OrcBoss' || p.name === 'Wolf')); + if (dead) { + try { + const r = removeParticipant(enc, dead.id); + enc = await patch(encounterPath, enc, r, `remove dead ${dead.name}`); + } catch (e) { /* ignore */ } + } + } + + // 7. addParticipant (reinforcements): every 10 turns a new monster joins. + 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 }, + ]); + try { + const built = buildMonsterParticipant(spec).participant; + const r = addParticipant(enc, built); + enc = await patch(encounterPath, enc, r, `add ${spec.name}`); + reinforcementsAdded++; + } catch (e) { /* ignore */ } + } + + // 8. updateParticipant: every 7 turns, edit a field on someone (e.g. temp AC). + if (totalTurns % 7 === 0) { + const living = enc.participants.filter(p => p.currentHp > 0); + if (living.length > 0) { + const tgt = pick(living); + try { + const r = updateParticipant(enc, tgt.id, { notes: `edited@turn${totalTurns}` }); + enc = await patch(encounterPath, enc, r, `edit ${tgt.name} notes`); + } catch (e) { /* ignore */ } + } + } + + // 9. togglePause: every 12 turns, pause (resumes next iteration via above). + if (totalTurns % 12 === 0 && !lastPaused) { + enc = await patch(encounterPath, enc, togglePause(enc), 'pause'); + lastPaused = true; + } + + // 10. reorderParticipants: every 8 turns, shuffle initiative slightly. + if (totalTurns % 8 === 0 && lastReorder !== totalTurns) { + const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false); + if (living.length >= 2) { + // bump first participant's initiative by +1 (deterministic reorder). + const tgt = living[0]; + const newInit = (tgt.initiative || 0) + 1; + try { + const reordered = [...enc.participants].map(p => p.id === tgt.id ? { ...p, initiative: newInit } : p); + const r = reorderParticipants(enc, reordered); + enc = await patch(encounterPath, enc, r, `reorder (${tgt.name} init→${newInit})`); + lastReorder = totalTurns; + } catch (e) { /* ignore */ } + } + } + + console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName}`); await sleep(DELAY); guard++; if (!enc.isStarted) { console.log('combat auto-ended'); break; } } if (!enc.isStarted) { console.log('combat auto-ended'); break; } - console.log(`--- round ${roundN} complete (${turnInRound} turns total) ---`); - turnInRound = 0; + const alive = enc.participants.filter(p => p.currentHp > 0).length; + console.log(`--- round ${roundN} complete (turns=${totalTurns}, alive=${alive}) ---`); + + // revive dead: heal to full + reactivate. Sustains combat for 100 rounds + // and exercises toggleActive reactivate + heal-from-zero path. + const dead = enc.participants.filter(p => p.currentHp <= 0 || p.isActive === false); + for (const d of dead) { + try { + if (d.isActive === false) { + enc = await patch(encounterPath, enc, toggleParticipantActive(enc, d.id), `revive-reactivate ${d.name}`); + } + const h = applyHpChange(enc, d.id, 'heal', d.maxHp); + enc = await patch(encounterPath, enc, h, `revive-heal ${d.name} →${d.maxHp}`); + } catch (e) { console.log(` revive ${d.name} err: ${e.message}`); } + } } console.log(`replay: ${totalTurns} total turns across ${ROUNDS} rounds`); // end enc = await storage.getDoc(encounterPath); - if (enc.isStarted) { - const end = endEncounter(enc); - if (end.patch) await storage.updateDoc(encounterPath, end.patch); - } + if (enc.isStarted) enc = await patch(encounterPath, enc, endEncounter(enc), 'endEncounter'); await storage.updateDoc(activeDisplayPath, { activeCampaignId: null, activeEncounterId: null }); console.log('replay done'); } function firstActiveName(enc) { if (!enc.currentTurnParticipantId) return '(none)'; - const p = enc.participants.find(x => x.id === enc.currentTurnParticipantId); + const p = currentParticipant(enc); return p ? p.name : '(missing)'; } +function currentParticipant(enc) { + if (!enc.currentTurnParticipantId) return null; + return (enc.participants || []).find(x => x.id === enc.currentTurnParticipantId) || null; +} + main().catch(err => { console.error('replay failed:', err); process.exit(1); }); diff --git a/scripts/repro-pause-bug.js b/scripts/repro-pause-bug.js new file mode 100644 index 0000000..6f50f6c --- /dev/null +++ b/scripts/repro-pause-bug.js @@ -0,0 +1,76 @@ +// scripts/repro-pause-bug.js +// Minimal repro: pause+resume causes nextTurn to repeat same participant forever. +'use strict'; +const shared = require('../shared'); +const { makeParticipant, startEncounter, nextTurn, togglePause, addParticipant } = shared; + +function p(id, init) { + return makeParticipant({ id, name: id, type: 'monster', initiative: init, maxHp: 100, currentHp: 100 }); +} + +let e = { + name: 't', participants: [p('a', 20), p('b', 15), p('c', 10)], + isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], +}; +e = { ...e, ...startEncounter(e).patch }; +console.log('start:', { current: e.currentTurnParticipantId, order: e.turnOrderIds, round: e.round }); + +// advance 1 turn +e = { ...e, ...nextTurn(e).patch }; +console.log('turn1:', { current: e.currentTurnParticipantId, round: e.round }); + +// pause then resume immediately +e = { ...e, ...togglePause(e).patch }; +console.log('paused:', { isPaused: e.isPaused, current: e.currentTurnParticipantId, order: e.turnOrderIds }); +e = { ...e, ...togglePause(e).patch }; +console.log('resumed:', { isPaused: e.isPaused, current: e.currentTurnParticipantId, order: e.turnOrderIds }); + +// advance 5 turns — should visit b, c, a, b, c +const visited = [e.currentTurnParticipantId]; +for (let i = 0; i < 5; i++) { + e = { ...e, ...nextTurn(e).patch }; + visited.push(e.currentTurnParticipantId); +} +console.log('5 turns after resume:', visited); + +// now repro with addParticipant while paused +let e2 = { + name: 't', participants: [p('a', 20), p('b', 15), p('c', 10)], + isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], +}; +e2 = { ...e2, ...startEncounter(e2).patch }; +e2 = { ...e2, ...nextTurn(e2).patch }; // current=b +const newP = makeParticipant({ id: 'x', name: 'x', type: 'monster', initiative: 25, maxHp: 100, currentHp: 100 }); +e2 = { ...e2, ...addParticipant(e2, newP).patch }; +console.log('\nadded x while running:', { current: e2.currentTurnParticipantId, order: e2.turnOrderIds }); +e2 = { ...e2, ...togglePause(e2).patch }; +e2 = { ...e2, ...togglePause(e2).patch }; +console.log('after pause/resume:', { current: e2.currentTurnParticipantId, order: e2.turnOrderIds }); +const v2 = [e2.currentTurnParticipantId]; +for (let i = 0; i < 5; i++) { + e2 = { ...e2, ...nextTurn(e2).patch }; + v2.push(e2.currentTurnParticipantId); +} +console.log('5 turns after add+pause/resume:', v2); + +// repro 3: addParticipant WHILE paused, then resume +let e3 = { + name: 't', participants: [p('a', 20), p('b', 15), p('c', 10)], + isStarted: false, isPaused: false, round: 0, currentTurnParticipantId: null, turnOrderIds: [], +}; +e3 = { ...e3, ...startEncounter(e3).patch }; +e3 = { ...e3, ...nextTurn(e3).patch }; // current=b +console.log('\n--- add while PAUSED ---'); +e3 = { ...e3, ...togglePause(e3).patch }; // pause +console.log('paused:', { current: e3.currentTurnParticipantId, order: e3.turnOrderIds }); +const np = makeParticipant({ id: 'x', name: 'x', type: 'monster', initiative: 25, maxHp: 100, currentHp: 100 }); +e3 = { ...e3, ...addParticipant(e3, np).patch }; +console.log('add-while-paused:', { current: e3.currentTurnParticipantId, order: e3.turnOrderIds }); +e3 = { ...e3, ...togglePause(e3).patch }; // resume (rebuilds order) +console.log('resumed:', { current: e3.currentTurnParticipantId, order: e3.turnOrderIds }); +const v3 = [e3.currentTurnParticipantId]; +for (let i = 0; i < 5; i++) { + e3 = { ...e3, ...nextTurn(e3).patch }; + v3.push(e3.currentTurnParticipantId); +} +console.log('5 turns after add-while-paused+resume:', v3); diff --git a/shared/turn.characterization.test.js b/shared/turn.characterization.test.js index 10f5b46..9def45b 100644 --- a/shared/turn.characterization.test.js +++ b/shared/turn.characterization.test.js @@ -337,4 +337,14 @@ describe('addParticipant', () => { const { patch } = addParticipant(enc([p('a', 10)]), np); expect(patch.participants.map(x => x.id)).toEqual(['a', 'z']); }); + + test('rejects duplicate id (skip-bug root cause)', () => { + // Two participants with same id → togglePause resume rebuilds order with + // dup id twice → nextTurn gets stuck repeating that id forever. + // Audit found this in 100-round replay (addParticipant fired while paused + // because nextTurn threw, loop spun, same totalTurns %10 → re-added). + const existing = p('x', 5); + const dup = makeParticipant({ id: 'x', name: 'x2', type: 'monster', initiative: 10, maxHp: 100, currentHp: 100 }); + expect(() => addParticipant(enc([p('a', 10), existing]), dup)).toThrow(); + }); }); diff --git a/shared/turn.round-rotation.test.js b/shared/turn.round-rotation.test.js new file mode 100644 index 0000000..f489057 --- /dev/null +++ b/shared/turn.round-rotation.test.js @@ -0,0 +1,175 @@ +// 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); + }); +}); +