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>
130 lines
7.0 KiB
JavaScript
130 lines
7.0 KiB
JavaScript
/**
|
||
* 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.`);
|