Files
RingBRP/tools/playthrough.mjs
T
slaguru666andClaude Opus 5 6e3ea8fa19 playthrough: read a scenario's own encounter, against its own cast
The tool could only narrate the guard's fight — the frozen four, at the pack size
focus-baseline records. CLEAN GROUND's EXPOSURE table measures something else entirely: a
declared cast of six at counts of 1, 3, 6, 10 and 15, which is the fight a GM running that
case will actually referee and the one whose numbers we have just re-pinned.

--creature, --count, --party, --seed and --mode now mirror tools/simulate.mjs, so a row
measured there can be read here in words. Positional form still works.

  node tools/playthrough.mjs --creature quiet_neighbours_npc --count 6 \
    --party ashcroft,bhattacharya,renshaw,braithwaite,pollard,okonkwo --seed 11

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:55:45 +01:00

129 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } 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: <key> <seed> <mode>. */
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 same generator shape the guards use, so a seed here names the same fight there. */
const mk = seed => { let s = seed >>> 0; return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296); };
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 → ${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.`);