// scripts/replay-combat.js // 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 200 'use strict'; const shared = require('../shared'); const { buildCharacterParticipant, buildMonsterParticipant, 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) || 100; const DELAY = parseInt(process.argv[3], 10) || 200; const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; const PUB = `artifacts/${APP_ID}/public/data`; // Mirror App.js getPath. Adapter takes these; norm() strips prefix. const getPath = { campaigns: () => `${PUB}/campaigns`, campaign: (id) => `${PUB}/campaigns/${id}`, encounters: (cid) => `${PUB}/campaigns/${cid}/encounters`, encounter: (cid, eid) => `${PUB}/campaigns/${cid}/encounters/${eid}`, activeDisplay: () => `${PUB}/activeDisplay/status`, }; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); // 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}]`); // emit pointer-advance line when a MUTATION changes currentTurnParticipantId. // nextTurn passes label=null — it's a normal advance, already logged via // the turn line. Emitting pointer for it double-counts. const oldCur = enc.currentTurnParticipantId; const oldRound = enc.round; const newEnc = { ...enc, ...result.patch }; const newCur = newEnc.currentTurnParticipantId; const newRound = newEnc.round; if (label && oldCur && newCur && oldCur !== newCur) { const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur; const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur; const wrap = oldRound !== newRound ? ' wrap' : ''; console.log(` [pointer ${oldName}→${newName}${wrap}]`); } return newEnc; } 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}`); const campaignId = crypto.randomUUID(); const encounterId = crypto.randomUUID(); await storage.setDoc(getPath.campaign(campaignId), { name: 'Replay Campaign', playerDisplayBackgroundUrl: '', ownerId: 'replay', createdAt: new Date().toISOString(), players: [ { 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 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: 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 = [ ...charSpecs.map(c => buildCharacterParticipant(c).participant), ...monsterSpecs.map(m => buildMonsterParticipant(m).participant), ]; await storage.setDoc(getPath.encounter(campaignId, encounterId), { name: 'Big Boss Replay', campaignId, createdAt: new Date().toISOString(), participants, round: 0, currentTurnParticipantId: null, isStarted: false, isPaused: false, turnOrderIds: [], }); console.log(`created: campaign=${campaignId} encounter=${encounterId} participants=${participants.length}`); await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounterId, hidePlayerHp: false, }); await sleep(800); const encounterPath = getPath.encounter(campaignId, encounterId); const activeDisplayPath = getPath.activeDisplay(); // start let enc = await storage.getDoc(encounterPath); enc = await patch(encounterPath, enc, startEncounter(enc), 'startEncounter'); console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`); await sleep(DELAY); 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 + 2) * 2; let guard = 0; while (enc.round < roundN + 1 && guard < cap) { // NOTE: do NOT getDoc here — async re-fetch can return stale state and // cause nextTurn to compute off pre-mutation data (double-acts/skips). // Trust the local enc returned by patch (sync spread of updateDoc). // 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 actor = currentParticipant(enc); console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${enc.turnOrderIds.map(id=>enc.participants.find(p=>p.id===id)?.name||id).join(',')}] cur=${enc.currentTurnParticipantId}`); // 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})`); } } } // 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 */ } } } await sleep(DELAY); guard++; if (!enc.isStarted) { console.log('combat auto-ended'); break; } } if (!enc.isStarted) { console.log('combat auto-ended'); break; } 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) 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 = 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); });