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

347 lines
14 KiB
JavaScript
Raw Normal View History

// 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}]`);
return { ...enc, ...result.patch };
}
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) {
enc = await storage.getDoc(encounterPath);
// 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);
// 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 */ }
}
}
console.log(` turn ${totalTurns} (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; }
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); });