7866dec83b
- ROUNDS now = full initiative cycles (not turns). Each round advances initiative until round counter ticks (all participants act). - Visible damage: current actor hits random living target for 3-10 dmg. Player view sees HP bars change live. - Default delay 200ms (was 800ms). - Reproduces M4 skip bug: rounds shrink as participants die (8→7→2→1). - Label accuracy: 'turn N (round X)'.
177 lines
6.7 KiB
JavaScript
177 lines
6.7 KiB
JavaScript
// 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); });
|