Files
RingBRP/tools/first-blood.mjs
T
slaguru666andClaude Opus 5 60995b28c2 R-267 addendum: read the cast, do not copy it
I told the scenario session that this guard turns a silent re-cast into a build
failure, and it accepted the coupling on that basis. The cast was hardcoded, so
a re-cast would have left it measuring the old six and reporting success.

Reads the <!-- cast: --> marker check-rollable established, drops the two the
scaling note drops by name rather than by position, and refuses to run if the
marker is gone. Verified by re-casting CLEAN GROUND in a throwaway worktree and
watching the guard name the change.

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

274 lines
13 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: both sides disable on one good
* hit, so whoever lands it first removes an attacker, 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, readFileSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { runFight, seedFor, makeRng } from "./simulate.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 rather than copied out of it. A cast written here by hand would
go on measuring the old six after a re-cast and report success, which is the defect this
project keeps finding in itself and the one thing a guard must not do. check-rollable
established the marker; this reads the same one. */
const SCENARIO = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND.md");
/* The scaling note drops Pollard at five and Okonkwo as well at four, so the cut is the
declared cast less those two. Named rather than sliced: "the first four" would survive
a reordering of the cast list and quietly measure different people. */
const DROPPED_AT_FOUR = ["pollard", "okonkwo"];
function declaredCast() {
if (!existsSync(SCENARIO)) {
console.error(`first-blood: ${path.relative(ROOT, SCENARIO)} is missing, and the cast is read `
+ `from it. Nothing here can be measured against a scenario that is not there.`);
process.exit(1);
}
const raw = readFileSync(SCENARIO, "utf8");
const m = raw.match(/<!--\s*cast:\s*([^>]*?)\s*-->/i);
if (!m) {
console.error(`first-blood: ${path.relative(ROOT, SCENARIO)} declares no cast. Add the `
+ `<!-- cast: ... --> marker check-rollable already reads, or this guard is measuring `
+ `a fight the scenario no longer casts.`);
process.exit(1);
}
return m[1].split(",").map(x => x.trim().replace(/^pc_/, "")).filter(Boolean);
}
const LINE = declaredCast();
const CUT = LINE.filter(k => !DROPPED_AT_FOUR.includes(k));
if (CUT.length !== 4 || LINE.length !== 6) {
console.error(`first-blood: CLEAN GROUND now casts ${LINE.length} and its four-player cut `
+ `works out at ${CUT.length}. The recorded fights are six against six and four against `
+ `three; re-read the scaling note and re-record with --update rather than reverting.`);
process.exit(1);
}
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`);