Files
RingBRP/tools/playthrough.mjs
T
slaguru666andClaude Opus 5 8dbe885a34 Add check-seam: one definition of the NPC population
collectSpecs() in tools/all-specs.mjs is the single place that decides
which specs exist. Nine modules were importing NPCS and PREGENS straight
from content.mjs instead, so each carried its own idea of the population
and measured a different subset of the game. check-lethality was the
clearest case: it scored 47 creatures and reported OK for all 184.

check-seam (guard 24, first in the suite) scans every module and fails if
anything but all-specs.mjs names NPCS or PREGENS in a content.mjs import.
The nine violators are re-pointed at the seam.

Re-recording check-focus's baseline against the full 184 raised it from
47 packs to 138 and surfaced one creature focus fire does not help: the
dun cow. Rather than re-record that away, the guard now requires the
advice section of BESTIARY.md to name every such exception, and
bestiary.mjs generates the sentence.

The first version of that check asked whether the name appeared anywhere
in BESTIARY.md, which every creature's own heading satisfies — deleting
the exception sentence still passed. It reads only the
"Shooting at something that moves" section now, and the mutation test
fails as it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:52:18 +01:00

134 lines
7.3 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";
/* The whole population, through the one door. Reading NPCS from content.mjs meant
this tool could only see the 47 creatures that happen to live in that file, so a
creature from tools/bestiary-*.mjs was simply "not found". See check-seam.mjs. */
import { collectSpecs } from "./all-specs.mjs";
const NPCS = (await collectSpecs()).map(r => r.spec);
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.`);