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>
274 lines
13 KiB
JavaScript
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`);
|