// 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 BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4001'; const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default'; const BASE = `artifacts/${APP_ID}/public/data`; const ROUNDS = parseInt(process.argv[2], 10) || 100; const DELAY = parseInt(process.argv[3], 10) || 800; const sleep = (ms) => new Promise(r => setTimeout(r, ms)); async function api(method, path, query, body) { let url = `${BACKEND}${path}`; if (query) url += '?' + new URLSearchParams(query).toString(); const res = await fetch(url, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`API ${method} ${path} ${res.status}: ${t}`); } const text = await res.text(); return text ? JSON.parse(text) : null; } const docGet = (p) => api('GET', '/api/doc', { path: p }).then(r => r && r.data); const docSet = (p, data) => api('PUT', '/api/doc', null, { path: p, data }); const docPatch = (p, patch) => api('PATCH', '/api/doc', null, { path: p, patch }); async function main() { console.log(`replay-combat: ${ROUNDS} rounds, ${DELAY}ms/step, backend=${BACKEND}`); // campaign + encounter const campaignId = crypto.randomUUID(); const encounterId = crypto.randomUUID(); const campaignPath = `${BASE}/campaigns/${campaignId}`; const encounterPath = `${BASE}/campaigns/${campaignId}/encounters/${encounterId}`; const activeDisplayPath = `${BASE}/activeDisplay/status`; await docSet(campaignPath, { 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 docSet(encounterPath, { 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 docSet(activeDisplayPath, { activeCampaignId: campaignId, activeEncounterId: encounterId, hidePlayerHp: false, }); await sleep(1000); // let player view load // start let enc = await docGet(encounterPath); const start = startEncounter(enc); await docPatch(encounterPath, start.patch); enc = { ...enc, ...start.patch }; console.log(`combat started: round ${enc.round}, first=${firstActiveName(enc)}`); await sleep(DELAY); // main loop for (let r = 1; r <= ROUNDS; r++) { enc = await docGet(encounterPath); const t = nextTurn(enc); await docPatch(encounterPath, t.patch); enc = { ...enc, ...t.patch }; // damage front monster if (r % 2 === 0) { enc = await docGet(encounterPath); const orc = enc.participants.find(p => p.name === 'OrcBoss' && p.currentHp > 0); if (orc) { const h = applyHpChange(enc, orc.id, 'damage', 5); if (h.patch) { await docPatch(encounterPath, h.patch); enc = { ...enc, ...h.patch }; } } } if (r % 3 === 0) { enc = await docGet(encounterPath); const cleric = enc.participants.find(p => p.name === 'Cleric' && p.currentHp > 0); if (cleric) { const h = applyHpChange(enc, cleric.id, 'heal', 3); if (h.patch) { await docPatch(encounterPath, h.patch); enc = { ...enc, ...h.patch }; } } } if (r % 5 === 0) { enc = await docGet(encounterPath); const fighter = enc.participants.find(p => p.name === 'Fighter' && p.currentHp > 0); if (fighter) { const c = toggleCondition(enc, fighter.id, 'stunned'); if (c.patch) { await docPatch(encounterPath, c.patch); enc = { ...enc, ...c.patch }; } } } enc = await docGet(encounterPath); console.log(`round ${r}: current=${firstActiveName(enc)} | round=${enc.round}`); await sleep(DELAY); if (!enc.isStarted) { console.log('combat auto-ended'); break; } } // end enc = await docGet(encounterPath); if (enc.isStarted) { const end = endEncounter(enc); if (end.patch) await docPatch(encounterPath, end.patch); } await docPatch(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); });