/** * Play one encounter and read what happened, blow by blow. * * The lethality and focus guards print a number per creature, and a number cannot tell * you WHY a fight went the way it did. Three review-log entries in a row (R-258 to R-260) * were written from side harnesses that agreed with a number while modelling the wrong * fight. So this narrates the REAL `runFight` — the one the baselines are recorded from — * through its `say` hook, which reports decisions and never rolls. The narrated fight is * the same fight the guards measure, and check-lethality and check-focus both prove it by * still matching their baselines with the hook in place. * * npm run play the recorded pack, both targeting modes * node tools/playthrough.mjs redcap a named creature * node tools/playthrough.mjs redcap 20260913 a chosen seed * node tools/playthrough.mjs redcap 20260913 focus one mode only * node tools/playthrough.mjs --creature quiet_neighbours_npc --count 6 \\ * --party ashcroft,bhattacharya,renshaw,braithwaite,pollard,okonkwo --seed 11 * a scenario's own encounter, against its own cast * * The pack size comes from focus-baseline.json, so the fight you read is the fight * check-focus measures. Creatures it records as pinned — no pack size makes them an even * fight — are played solo, where targeting cannot mean anything anyway. */ import { readFileSync } from "node:fs"; import path from "node:path"; import { NPCS } from "./content.mjs"; import { ROSTER } from "./roster.mjs"; import { runFight, makeRng } from "./simulate.mjs"; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); const base = JSON.parse(readFileSync(path.join(ROOT, "tools", "lethality-baseline.json"), "utf8")); const focus = JSON.parse(readFileSync(path.join(ROOT, "tools", "focus-baseline.json"), "utf8")); /* Flags mirror tools/simulate.mjs, so a fight measured there can be read here with the same words. The positional form still works: . */ const argv = process.argv.slice(2); const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; }; const positional = argv.filter((a, i) => !a.startsWith("--") && !(i > 0 && argv[i - 1].startsWith("--"))); const KEY = opt("--creature", positional[0] ?? "redcap"); const SEED = Number(opt("--seed", positional[1] ?? 20260913)); const spec = NPCS.find(n => n.key === KEY); if (!spec) { console.error(`playthrough: no creature keyed "${KEY}". Try one of: ` + NPCS.slice(0, 6).map(n => n.key).join(", ") + ", ..."); process.exit(1); } /* The frozen four by default — the party every recorded number is measured against — or a named cast, for reading a scenario's own encounter rather than the guard's. */ const partyKeys = (opt("--party", "") || "").split(",").map(x => x.trim()).filter(Boolean); const party = (partyKeys.length ? partyKeys : base.party).map(k => { const found = ROSTER.find(r => r.key === k || r.key === "pc_" + k); if (!found) { console.error(`playthrough: "${k}" is not on the duty roster`); process.exit(1); } return found; }); const rec = focus.creatures[KEY]; /* A pinned row records the pack size that was TRIED, not one worth playing: the header above promises solo for those, and a tool that contradicts its own documentation is the defect this file exists to help find. Solo also matches what check-lethality records. */ const N = Number(opt("--count", 0)) || (rec?.gain !== undefined ? rec.n : 1); /* THE generator the guards use, not merely one of the same shape. A seed here names the first fight of `simulate.mjs --seed ` exactly. */ const mk = makeRng; const firstName = c => (c.name ?? "?").split(" ")[0]; const hp = c => `${Math.max(0, c.hp)}/${c.hpMax}`; function play(mode) { /* Identical creatures need telling apart or the log is unreadable: A, B, C in the order they first do something. */ const tag = new Map(); const nameOf = c => { if (c.side === "party") return firstName(c); if (!tag.has(c)) tag.set(c, `${spec.name} ${String.fromCharCode(65 + tag.size)}`); return tag.get(c); }; const lines = []; const say = e => { if (e.kind === "round") { lines.push(`\nROUND ${e.round} — ${e.enemy.filter(x => x.hp > 0).length} of ${N} still up`); return; } if (e.kind === "turn") return; if (e.kind === "grounded") { lines.push(` >> ${nameOf(e.defender)} COMES DOWN — it is a ${e.plan} now.` + ` Hide ${e.hide} everywhere, ${e.vital} on the chest, and the wings are gone.`); return; } const a = nameOf(e.attacker), d = nameOf(e.defender); if (e.kind === "stopped") { lines.push(` ${a} → ${d}: ${e.roll} ${e.level.toUpperCase()} · ${e.location?.id ?? "?"}` + ` · ${e.rolled} vs armour ${e.armour}${e.effectiveArmour !== e.armour ? `→${e.effectiveArmour}` : ""}` + ` — stopped dead`); return; } if (e.kind === "miss") lines.push(` ${a} → ${d}: ${e.roll} vs ${e.target} — ${e.level}`); else if (e.kind === "dodged") lines.push(` ${a} → ${d}: ${e.roll} ${e.level.toUpperCase()} — dodged` + ` (${e.dodgeRoll} vs ${e.dodgeTarget}${e.penalty ? `, ${e.penalty} for defences already spent` : ""})`); else if (e.kind === "hit") lines.push(` ${a} → ${d}: ${e.roll} ${e.level.toUpperCase()}${e.landing !== e.level ? ` → lands ${e.landing}` : ""}` + ` · ${e.location?.id ?? "?"} · ${e.rolled} vs armour ${e.armour}${e.effectiveArmour !== e.armour ? `→${e.effectiveArmour}` : ""}` + ` = ${e.taken} · ${d} ${hp(e.defender)}` + `${e.majorWound ? " · MAJOR WOUND" : ""}${e.destroyed ? " · DESTROYED" : e.disabled ? " · disabled" : ""}` + `${e.defender.hp <= 0 ? " ** DOWN **" : ""}`); }; const r = runFight(mk(SEED), party, Array(N).fill(spec), { partyTargets: mode, say }); return { r, lines }; } const MODE = opt("--mode", positional[2]); const MODES = MODE ? [MODE] : (N > 1 ? ["random", "focus"] : ["random"]); for (const mode of MODES) { const { r, lines } = play(mode); const how = N === 1 ? "" : mode === "focus" ? " — party CONCENTRATING FIRE" : " — party picking targets at random"; console.log(`\n${"=".repeat(78)}\n${N} × ${spec.name.toUpperCase()}${how}\n${"=".repeat(78)}`); console.log(lines.join("\n")); console.log(`\n → [${N > 1 ? mode : "solo"}] ${r.won ? "PARTY WINS" : r.wiped ? "PARTY WIPED" : "neither side finished it"}` + ` in ${r.rounds} rounds · ${r.partyDown} of ${party.length} down · ${r.dead} dead`); } if (rec?.gain !== undefined) console.log(`\nrecorded by check-focus over ${focus.runs} fights × ${focus.seeds.length} seeds:` + ` spread ${rec.spread}% · focus ${rec.focus}% · concentrating fire is worth ${rec.gain} points`); else if (rec?.pinned !== undefined) console.log(`\ncheck-focus records this one as pinned at ${rec.pinned}% — no pack size makes it` + ` an even fight, so it is played solo here.`);