// 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. // Uses shared/turn.js for all turn logic (same model as the UI). // // Run: node scripts/replay-combat.js [rounds] [delayMs] // rounds default 100, delayMs default 800 'use strict'; const shared = require('../shared'); const { buildCharacterParticipant, buildMonsterParticipant, startEncounter, nextTurn, applyHpChange, toggleCondition, 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 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, no // hand-built paths — adapter normalizes internally. Catches path-shape drift // that the earlier raw-REST replay caused. const storage = createWsStorage({ baseUrl: BACKEND, wsUrl: WS_URL }); 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(); await storage.setDoc(getPath.campaign(campaignId), { name: 'Replay Campaign', playerDisplayBackgroundUrl: '', 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 }, ], }); // 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 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 }, ]; const participants = [ ...chars.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}`); // point active display so player view shows it await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounterId, hidePlayerHp: false, }); await sleep(1000); 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 }; 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; for (let roundN = 1; roundN <= ROUNDS; roundN++) { // advance initiative until round counter ticks (full cycle done). const cap = (enc.participants.length + 1) * 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. 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})`); } } console.log(` turn ${turnInRound} (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; } 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); } 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); return p ? p.name : '(missing)'; } main().catch(err => { console.error('replay failed:', err); process.exit(1); });