750ee99080
Campaign card now shows created date/time next to char/encounter counts. Lets DM tell newest campaign apart (replay tool creates many). createdAt already set at campaign create (line 2174). Display renders formatted: 'Jul 1, 2026, 16:32'. replay-combat.js: campaign + encounter names now include timestamp (new Date().toLocaleString) for easy identification. WS collection push verified live (injected test campaigns appeared without reload).
378 lines
16 KiB
JavaScript
378 lines
16 KiB
JavaScript
// 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}]`);
|
|
// emit pointer-advance line when a MUTATION changes currentTurnParticipantId.
|
|
// nextTurn passes label=null — it's a normal advance, already logged via
|
|
// the turn line. Emitting pointer for it double-counts.
|
|
const oldCur = enc.currentTurnParticipantId;
|
|
const oldRound = enc.round;
|
|
const newEnc = { ...enc, ...result.patch };
|
|
const newCur = newEnc.currentTurnParticipantId;
|
|
const newRound = newEnc.round;
|
|
if (label && oldCur && newCur && oldCur !== newCur) {
|
|
const oldName = enc.participants.find(p => p.id === oldCur)?.name || oldCur;
|
|
const newName = newEnc.participants.find(p => p.id === newCur)?.name || newCur;
|
|
const wrap = oldRound !== newRound ? ' wrap' : '';
|
|
console.log(` [pointer ${oldName}→${newName}${wrap}]`);
|
|
}
|
|
return newEnc;
|
|
}
|
|
|
|
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 (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
|
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 (${new Date().toLocaleString('en-US', { hour12: false })})`,
|
|
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) {
|
|
// NOTE: do NOT getDoc here — async re-fetch can return stale state and
|
|
// cause nextTurn to compute off pre-mutation data (double-acts/skips).
|
|
// Trust the local enc returned by patch (sync spread of updateDoc).
|
|
|
|
// 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);
|
|
|
|
// Dump turn line with order AND initiative (DM drag may reorder without
|
|
// changing init — log both so parser can flag unexplained shifts).
|
|
const ordStr = enc.turnOrderIds.map(id => {
|
|
const p = enc.participants.find(x => x.id === id);
|
|
return p ? `${p.name}:${p.initiative}` : id;
|
|
}).join(',');
|
|
// Also dump participants[] order (display source). Diverge from order = sync bug.
|
|
const pStr = enc.participants.map(p => `${p.name}:${p.initiative}`).join(',');
|
|
console.log(` turn ${totalTurns} (round ${enc.round}): ${actorName} | order=[${ordStr}] parts=[${pStr}] cur=${enc.currentTurnParticipantId}`);
|
|
|
|
// 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, drag one past next (DM reorder).
|
|
if (totalTurns % 8 === 0 && lastReorder !== totalTurns) {
|
|
const living = enc.participants.filter(p => p.currentHp > 0 && p.isActive !== false);
|
|
if (living.length >= 3) {
|
|
// drag first past second (same-or-cross init, exercises reorder).
|
|
const dragged = living[0];
|
|
const target = living[1];
|
|
try {
|
|
const r = reorderParticipants(enc, dragged.id, target.id);
|
|
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${target.name}`);
|
|
lastReorder = totalTurns;
|
|
} catch (e) { /* same-init only — try same-init pair */
|
|
const sameInit = living.find(p => p !== dragged && p.initiative === dragged.initiative);
|
|
if (sameInit) {
|
|
const r = reorderParticipants(enc, dragged.id, sameInit.id);
|
|
enc = await patch(encounterPath, enc, r, `reorder ${dragged.name}→before ${sameInit.name}`);
|
|
lastReorder = totalTurns;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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); });
|