Files
ttrpg-initiative-tracker/scripts/replay-combat.js
T

169 lines
6.2 KiB
JavaScript
Raw Normal View History

// 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) || 100;
const DELAY = parseInt(process.argv[3], 10) || 800;
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
for (let r = 1; r <= ROUNDS; r++) {
enc = await storage.getDoc(encounterPath);
const t = nextTurn(enc);
await storage.updateDoc(encounterPath, t.patch);
enc = { ...enc, ...t.patch };
if (r % 2 === 0) {
enc = await storage.getDoc(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 storage.updateDoc(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
}
}
if (r % 3 === 0) {
enc = await storage.getDoc(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 storage.updateDoc(encounterPath, h.patch); enc = { ...enc, ...h.patch }; }
}
}
if (r % 5 === 0) {
enc = await storage.getDoc(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 storage.updateDoc(encounterPath, c.patch); enc = { ...enc, ...c.patch }; }
}
}
enc = await storage.getDoc(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 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); });