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>
134 lines
7.3 KiB
JavaScript
134 lines
7.3 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";
|
||
/* 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.`);
|