Files
RingBRP/tools/playthrough.mjs
T
slaguru666andClaude Opus 5 8627fa3536 One generator, so a citation names one fight (R-265)
tools/playthrough.mjs exists to show a reader why a measured number is what it is. I cited
it to another session — read seed 2, see the wipe the 45% row is made of — and the
reproduction failed in front of them. They reported different outcomes at both seeds and
guessed the cause correctly from outside: the two tools were not drawing from the same
stream.

They were not. simulate.mjs used a private mulberry32 makeRng; playthrough.mjs had its own
LCG written to look like it. Both deterministic, both reproducible alone, and "seed 2"
named a different fight in each — which breaks the only thing the tool is for. Its own
comment claimed a seed here names the same fight there. check-focus carried a third copy of
that LCG, so the two guards described the same game with different dice.

makeRng is exported and both files use it. A playthrough seed is now exactly the first
fight of simulate.mjs --seed <n>: --runs 1 --seed 2 and the playthrough give 9 rounds, 4 of
4 down, 1 dead, both.

The other half was my citation rather than the code: the command I sent omitted --mode, so
it plays both targeting arms and prints two fights. They read the last line, I quoted the
first. The summary line now names the arm.

check-focus re-recorded under the shared stream; figures move a point or two. What it buys
is that the bimodality analysis now reproduces the published means exactly — 2.31, 3.03,
0.40, 0.42 against the four rows CLEAN GROUND publishes. Under the old LCG it agreed to
within a decimal, which looked like corroboration and was two experiments landing near each
other.

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

130 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, 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: <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 generator the guards use, not merely one of the same shape. A seed here names the
first fight of `simulate.mjs --seed <n>` 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.`);