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>
248 lines
12 KiB
JavaScript
248 lines
12 KiB
JavaScript
/**
|
|
* first-blood — who lands the first disabling blow, and what the fight does afterwards.
|
|
*
|
|
* check-lethality and check-focus record how a fight ENDS. Neither records how it TURNS,
|
|
* and the four-player cut of CLEAN GROUND turns early: one hit that meets a limb's own
|
|
* capacity disables it — 4 or 5 points either way here, `locationMaxHp` — and both sides
|
|
* are made of limbs that small, so whoever lands it first costs the other 30 points off the skills that matter,
|
|
* which thins the return fire, which
|
|
* makes the next disable likelier. Nothing pulls the fight back toward the middle once it
|
|
* has tipped, and that — not anything about round five — is where the scenario's bimodal
|
|
* outcome comes from.
|
|
*
|
|
* R-266 measured that in a scratch harness and another session, correctly, would not put
|
|
* it in a scenario: a figure nobody can re-derive is a claim. So the measurement is
|
|
* recorded as an artifact and re-run on every build, the way check-focus does it, and
|
|
* the reading tool and the guard are the same file so they cannot drift apart.
|
|
*
|
|
* node tools/first-blood.mjs read the configured fights
|
|
* node tools/first-blood.mjs --check compare against the committed baseline
|
|
* node tools/first-blood.mjs --update re-record it (after a deliberate change)
|
|
* node tools/first-blood.mjs --creature redcap --count 3 --runs 500
|
|
* an unrecorded fight, for reading only
|
|
*
|
|
* WHAT IS MEASURED. The first hit of the fight that disables a location, destroys one,
|
|
* or drops a fighter — the three conditions the narration marks — and which side landed
|
|
* it. Then the wipe rate on each side of that, which is what the first blow is worth.
|
|
*
|
|
* WHY TWO CONFIGURATIONS. The cut is the claim; six against six is the control. One
|
|
* fighter is a quarter of the cut and a sixth of the line, so the same blow compounds
|
|
* less, and the difference between the two swings is what makes this a property of the
|
|
* cut rather than of the creature.
|
|
*/
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { existsSync } 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, seedFor, makeRng } from "./simulate.mjs";
|
|
import { castAndCut } from "./declared-cast.mjs";
|
|
|
|
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
|
|
const BASELINE = path.join(ROOT, "tools", "first-blood-baseline.json");
|
|
|
|
const argv = process.argv.slice(2);
|
|
const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
|
|
const UPDATE = argv.includes("--update");
|
|
const CHECK = argv.includes("--check");
|
|
|
|
const RUNS = Number(opt("--runs", 2000));
|
|
const SEEDS = [11, 4242, 90210];
|
|
|
|
/* CLEAN GROUND's own cast, not the frozen party of the other two guards: this measures a
|
|
scenario's encounter, and the whole finding is about what four of these people can do.
|
|
Read from the document by tools/scenario-cast.mjs, which check-attackers reads too. */
|
|
const { line: LINE, cut: CUT } = castAndCut("first-blood");
|
|
|
|
const CONFIGS = [
|
|
{ id: "cut", label: "four of the roster vs three of the column", party: CUT, creature: "quiet_neighbours_npc", count: 3 },
|
|
{ id: "column", label: "six of the roster vs six of the column", party: LINE, creature: "quiet_neighbours_npc", count: 6 }
|
|
];
|
|
|
|
const roster = keys => keys.map(k => {
|
|
const found = ROSTER.find(r => r.key === k || r.key === "pc_" + k);
|
|
if (!found) { console.error(`first-blood: "${k}" is not on the duty roster`); process.exit(1); }
|
|
return found;
|
|
});
|
|
const creature = key => {
|
|
const spec = NPCS.find(n => n.key === key);
|
|
if (!spec) { console.error(`first-blood: no creature keyed "${key}"`); process.exit(1); }
|
|
return spec;
|
|
};
|
|
const mean = a => a.reduce((x, y) => x + y, 0) / a.length;
|
|
const r1 = n => Number(n.toFixed(1));
|
|
|
|
/** One sweep: who struck first, in what round, and how those fights ended. */
|
|
function sweep(party, foes, seed, runs) {
|
|
const by = { party: { n: 0, wiped: 0, rounds: 0 }, column: { n: 0, wiped: 0, rounds: 0 } };
|
|
let undecided = 0;
|
|
for (let i = 0; i < runs; i++) {
|
|
let first = null, firstRound = 0, round = 0;
|
|
const r = runFight(makeRng(seed + i * 2654435761), party, foes, { say: e => {
|
|
if (e.kind === "round") round = e.round;
|
|
if (first) return;
|
|
if (e.kind === "hit" && (e.disabled || e.destroyed || e.defender.hp <= 0)) {
|
|
first = e.defender.side === "enemy" ? "party" : "column";
|
|
firstRound = round;
|
|
}
|
|
}});
|
|
if (!first) { undecided++; continue; }
|
|
const b = by[first];
|
|
b.n++; b.rounds += firstRound;
|
|
if (r.wiped) b.wiped++;
|
|
}
|
|
const decided = by.party.n + by.column.n;
|
|
return {
|
|
firstShare: (100 * by.party.n) / Math.max(decided, 1),
|
|
meanRound: (by.party.rounds + by.column.rounds) / Math.max(decided, 1),
|
|
wipeIfParty: (100 * by.party.wiped) / Math.max(by.party.n, 1),
|
|
wipeIfColumn: (100 * by.column.wiped) / Math.max(by.column.n, 1),
|
|
undecided
|
|
};
|
|
}
|
|
|
|
/** The recorded measurement: three seeds, averaged, with the seed-to-seed spread kept. */
|
|
function measure(cfg, runs = RUNS) {
|
|
const party = roster(cfg.party);
|
|
const foes = Array(cfg.count).fill(creature(cfg.creature));
|
|
const each = SEEDS.map(s => sweep(party, foes, seedFor(s, cfg.id), runs));
|
|
const swings = each.map(e => e.wipeIfColumn - e.wipeIfParty);
|
|
const shares = each.map(e => e.firstShare);
|
|
return {
|
|
party: cfg.party, creature: cfg.creature, count: cfg.count,
|
|
firstShare: r1(mean(shares)),
|
|
meanRound: r1(mean(each.map(e => e.meanRound))),
|
|
wipeIfParty: r1(mean(each.map(e => e.wipeIfParty))),
|
|
wipeIfColumn: r1(mean(each.map(e => e.wipeIfColumn))),
|
|
swing: r1(mean(swings)),
|
|
shareNoise: r1(Math.max(...shares) - Math.min(...shares)),
|
|
swingNoise: r1(Math.max(...swings) - Math.min(...swings)),
|
|
undecided: each.reduce((n, e) => n + e.undecided, 0)
|
|
};
|
|
}
|
|
|
|
/* An ad-hoc fight, for reading rather than recording. Nothing here reaches the baseline. */
|
|
if (!UPDATE && !CHECK && (argv.includes("--creature") || argv.includes("--party") || argv.includes("--count"))) {
|
|
const cfg = {
|
|
id: "ad-hoc", label: "an unrecorded fight",
|
|
party: (opt("--party", CUT.join(","))).split(",").map(x => x.trim()).filter(Boolean),
|
|
creature: opt("--creature", "quiet_neighbours_npc"),
|
|
count: Number(opt("--count", 3))
|
|
};
|
|
const m = measure(cfg, RUNS);
|
|
console.log(`\nfirst fighter out of the fight — ${RUNS} runs x ${SEEDS.length} seeds, unrecorded`);
|
|
console.log(`${cfg.party.length} of the roster vs ${cfg.count} x ${creature(cfg.creature).name ?? cfg.creature}\n`);
|
|
console.log(` party strikes first in ${m.firstShare}% of fights (mean round ${m.meanRound})`);
|
|
console.log(` wipes: ${m.wipeIfParty}% when the party strikes first, ${m.wipeIfColumn}% when it does not `
|
|
+ `— a swing of ${m.swing}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (UPDATE) {
|
|
const configs = {};
|
|
for (const cfg of CONFIGS) configs[cfg.id] = { label: cfg.label, ...measure(cfg) };
|
|
await writeFile(BASELINE, JSON.stringify({
|
|
note: "Generated by tools/first-blood.mjs --update. Do not edit by hand.",
|
|
runs: RUNS, seeds: SEEDS, configs
|
|
}, null, 2) + "\n", "utf8");
|
|
console.log(`first-blood: baseline recorded — ${CONFIGS.length} configurations, `
|
|
+ `${RUNS} runs x ${SEEDS.length} seeds`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!existsSync(BASELINE)) {
|
|
console.error("first-blood: no baseline. Run with --update to record one.");
|
|
process.exit(1);
|
|
}
|
|
const base = JSON.parse(await readFile(BASELINE, "utf8"));
|
|
|
|
if (!CHECK) {
|
|
console.log(`\nfirst fighter out of the fight — ${base.runs} runs x ${base.seeds.length} seeds`);
|
|
for (const [id, was] of Object.entries(base.configs)) {
|
|
console.log(`\n ${was.label}`);
|
|
console.log(` party strikes first in ${was.firstShare}% of fights (mean round ${was.meanRound})`);
|
|
console.log(` wipes: ${was.wipeIfParty}% when the party strikes first, ${was.wipeIfColumn}% when it does not `
|
|
+ `— a swing of ${was.swing}`);
|
|
}
|
|
console.log(`\n recorded by tools/first-blood.mjs --update; re-checked on every build.\n`);
|
|
process.exit(0);
|
|
}
|
|
|
|
/* ---- --check ------------------------------------------------------------------ */
|
|
|
|
if (base.runs !== RUNS || base.seeds?.join() !== SEEDS.join()) {
|
|
console.error(`first-blood: FAILED — the baseline was recorded under different conditions `
|
|
+ `(${base.runs} runs, seeds ${base.seeds?.join(",")}). Re-record it with --update.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/* Exact comparison is only honest if the measurement is exact. Prove it, don't assume. */
|
|
{
|
|
const probe = CONFIGS[0];
|
|
const a = JSON.stringify(measure(probe, 200));
|
|
const b = JSON.stringify(measure(probe, 200));
|
|
if (a !== b) {
|
|
console.error("first-blood: FAILED — the simulation is not deterministic, so an exact "
|
|
+ `baseline cannot mean anything. ${probe.id} measured twice gave ${a} and ${b}.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const now = {}, problems = [];
|
|
for (const cfg of CONFIGS) {
|
|
const was = base.configs[cfg.id];
|
|
if (!was) { problems.push(` ${cfg.id}: not in the baseline — re-record with --update`); continue; }
|
|
if (was.party.join() !== cfg.party.join()) {
|
|
const gone = was.party.filter(k => !cfg.party.includes(k));
|
|
const added = cfg.party.filter(k => !was.party.includes(k));
|
|
problems.push(` ${cfg.id}: the scenario now casts different people — `
|
|
+ (gone.length ? `no longer ${gone.join(", ")}` : "the same six, reordered")
|
|
+ (added.length ? `, now ${added.join(", ")}` : "")
|
|
+ `. The recorded swing describes the old cast; re-record with --update, do not revert the cast.`);
|
|
continue;
|
|
}
|
|
if (was.creature !== cfg.creature || was.count !== cfg.count) {
|
|
problems.push(` ${cfg.id}: recorded against ${was.count} x ${was.creature}, now ${cfg.count} x ${cfg.creature}`);
|
|
continue;
|
|
}
|
|
const m = now[cfg.id] = measure(cfg);
|
|
for (const k of ["firstShare", "meanRound", "wipeIfParty", "wipeIfColumn", "swing"]) {
|
|
if (m[k] !== was[k]) problems.push(` ${cfg.id}: ${k} ${was[k]} -> ${m[k]}`);
|
|
}
|
|
}
|
|
for (const id of Object.keys(base.configs)) {
|
|
if (!CONFIGS.some(c => c.id === id)) problems.push(` ${id}: gone from the tool — re-record with --update`);
|
|
}
|
|
|
|
if (problems.length) {
|
|
console.error(`first-blood: FAILED — the fight no longer turns the way it was recorded`);
|
|
problems.forEach(p => console.error(p));
|
|
process.exit(1);
|
|
}
|
|
|
|
/* The claims, restated as tests rather than as prose. Only what a page would assert: the
|
|
drift comparison above already catches every movement, and a guard that asserts more
|
|
than the finding needs is a guard that fails on news rather than on error — R-261. */
|
|
const cut = now.cut, col = now.column;
|
|
|
|
if (Math.abs(cut.firstShare - 50) > 5) {
|
|
console.error(`first-blood: FAILED — first blood is no longer near even in the cut `
|
|
+ `(${cut.firstShare}% to the party). The finding calls it a coin, and a coin that lands `
|
|
+ `one way ${cut.firstShare}% of the time is not one. Rewrite the finding, don't re-record.`);
|
|
process.exit(1);
|
|
}
|
|
if (cut.swing <= col.swing + cut.swingNoise + col.swingNoise) {
|
|
console.error(`first-blood: FAILED — the first blow is worth ${cut.swing} points in the cut and `
|
|
+ `${col.swing} in the six-a-side line, which is no longer a difference this sampling can see `
|
|
+ `(noise ${cut.swingNoise} and ${col.swingNoise}). The finding says the compounding is a `
|
|
+ `property of the cut; rewrite it rather than re-recording.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`first-blood: OK — first blood lands round ${cut.meanRound} and is worth ${cut.swing} points `
|
|
+ `of wipe rate in the cut, ${col.swing} in the line`);
|