Files
RingBRP/tools/check-focus.mjs
T
slaguru666andClaude Opus 5 ad5fd57568 The harness was fighting a different animal (R-264)
Reading a narrated supporter fight showed it being hit on armR and head. It has wings; it
has never had arms. Three defects, all in the pipeline every published number comes from.

1. It located hits by species, not body plan — simulate.mjs read defender.species where
   the game writes spec.bodyPlan ?? spec.species ?? "baseline" into speciesProfile
   (build-packs.mjs:294). The barghest, kelpie and church grim were fought on two legs
   with arms, and the supporter with no wings, so the fight its tactics call the one the
   agents can win could not occur in a measured fight.
2. Armour was one scalar for the whole creature, and the harness's locations had no
   armour field. Bare wings, the grounded halving and the vital exemption — R-254 and
   R-255 — were invisible to every number. Worn armour was summed the same way, which put
   a stab vest on a cleaner's head.
3. Nothing was ever grounded: a wing could be ruined and the creature kept flying.

Fixed in the game's order — locate, then apply what that location carries, every term
imported from rules.mjs. A ruined wing calls groundedPlanFor and the wounds carry across
by severity through remapLocationDamage, the function _preUpdate uses.

A bug of mine no guard would have caught: remapLocationDamage returns { damage, moved,
rescaled } and my first draft passed the whole object where a damage map was expected, so
every wound a creature carried was forgiven the moment it came down. check-lethality would
have passed it — fewer wounds means a longer fight, which reads as a number moving, and
this commit moves numbers. Found by probing a landing by hand.

20 of 47 creatures moved, 18 deadlier and 2 less. The supporter goes 69.3% -> 25.1% wiped,
3.38 -> 2.16 down, second deadliest to fourth: it was being measured as a 30-hit-point
creature in uniform armour 9 that could not be grounded. The small rises elsewhere are the
party's armour no longer covering locations it never protected.

check-focus then caught the page overclaiming, which is what it is for: focus fire still
helps in all 30 but only 28 clear their own noise where 31 of 31 did. The guard was
asserting more than the page needs — the bestiary prints that count from the artifact and
cannot overstate it — so it now checks only what the page asserts outright, and the page
rewrote itself to "in 28 of them".

Both baselines re-recorded. Minor version, not a patch: the published numbers changed.

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

201 lines
9.3 KiB
JavaScript

/**
* check-focus — the bestiary's advice must keep being true.
*
* R-258 put a tactic on a page a GM reads, measured in a harness nobody guards. R-259
* found it worth nothing when run through the simulator. R-260 found the replacement
* advice right for the wrong reason. Each time the correction was an assertion in a
* document, which is the same arrangement that let ddc4f99 describe a game nobody was
* playing: a number written down with nothing checking it.
*
* So the claim is recorded as an artifact and re-measured on every build, exactly the
* way check-lethality does it. The bestiary reads the artifact rather than restating it,
* so the page, the guard and the simulator cannot disagree.
*
* node tools/check-focus.mjs compare against the committed baseline
* node tools/check-focus.mjs --update re-record it (after a deliberate change)
* node tools/check-focus.mjs --verbose print every creature, not just drift
*
* WHAT IS MEASURED. For each creature, one pack size, and two arms: every agent picking
* a target at random, versus every agent putting their attacks into the same creature.
* The difference is what focus fire is worth against that creature.
*
* WHY A RECORDED PACK SIZE. A fight the party wins 100% of the time, or loses 100% of
* the time, cannot show an effect of any size — three of the first four packs measured
* in R-259 were pinned like that and said nothing. --update picks, for each creature,
* the size whose spread-fire rate sits nearest an even fight, and RECORDS it. The guard
* then re-measures at that size rather than choosing again, so a creature that changes
* shows up as drift instead of quietly moving to a different question.
*/
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { runFight, seedFor } 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", "focus-baseline.json");
const argv = process.argv.slice(2);
const UPDATE = argv.includes("--update");
const VERBOSE = argv.includes("--verbose");
/* The same frozen party as check-lethality, for the same reason and so the two guards
describe the same game. */
const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
/* 1000 rather than 500 on evidence, not preference: at 500 runs one row's gain no longer
clears its own seed-to-seed noise and the claim-check below fails, because the page's
"without exception" is not supported by that much sampling. The build pays 8s for a
sentence it is entitled to print. */
const RUNS = 1000;
const SEEDS = [11, 4242, 90210];
const SEED_MODE = "per-creature";
const SIZES = [2, 3, 4, 5, 6];
const SCAN_RUNS = 400;
/* A pack whose spread-fire rate falls outside this cannot move enough to measure, and a
row that cannot move is a row that would pass this guard whatever happened to it. */
const MEASURABLE = [15, 85];
const party = PARTY_KEYS.map(k => {
const found = ROSTER.find(r => r.key === k);
if (!found) {
console.error(`check-focus: the frozen party names "${k}", which is not on the roster.`);
process.exit(1);
}
return found;
});
/* One generator per arm, derived from the creature's own key, so inserting a creature
cannot perturb its neighbours — R-252, learned the hard way. */
const rngFor = seed => { let s = seed >>> 0; return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296); };
const winRate = (foes, targets, seed) => {
let won = 0;
for (let i = 0; i < RUNS; i++) {
if (runFight(rngFor(seed + i * 2654435761), party, foes, { partyTargets: targets }).won) won++;
}
return (100 * won) / RUNS;
};
const mean = a => a.reduce((x, y) => x + y, 0) / a.length;
const measureAt = (spec, n) => {
const foes = Array(n).fill(spec);
const seeds = SEEDS.map(s => seedFor(s, spec.key));
const spread = seeds.map(s => winRate(foes, "random", s));
const focus = seeds.map(s => winRate(foes, "focus", s));
return {
n,
spread: Number(mean(spread).toFixed(1)),
focus: Number(mean(focus).toFixed(1)),
gain: Number((mean(focus) - mean(spread)).toFixed(1)),
noise: Number((Math.max(...spread) - Math.min(...spread)).toFixed(1))
};
};
/* --update only: choose the pack size that makes the question answerable. */
const pickSize = spec => {
let best = null;
for (const n of SIZES) {
const w = winRate(Array(n).fill(spec), "random", seedFor(SEEDS[0], spec.key), SCAN_RUNS);
if (!best || Math.abs(w - 50) < Math.abs(best.w - 50)) best = { n, w };
if (w < 5) break; // already hopeless; more of them is worse
}
return best;
};
if (UPDATE) {
const creatures = {};
let measurable = 0;
for (const spec of NPCS) {
const pick = pickSize(spec);
if (pick.w < MEASURABLE[0] || pick.w > MEASURABLE[1]) {
creatures[spec.key] = { n: pick.n, pinned: Number(pick.w.toFixed(1)) };
continue;
}
creatures[spec.key] = measureAt(spec, pick.n);
measurable++;
}
await writeFile(BASELINE, JSON.stringify({
note: "Generated by tools/check-focus.mjs --update. Do not edit by hand.",
party: PARTY_KEYS, runs: RUNS, seeds: SEEDS, seedMode: SEED_MODE,
measurableBand: MEASURABLE, creatures
}, null, 2) + "\n", "utf8");
console.log(`check-focus: baseline recorded — ${measurable} of ${NPCS.length} creatures measurable, `
+ `${RUNS} runs x ${SEEDS.length} seeds, seed ${SEEDS[0]} ${SEED_MODE}`);
process.exit(0);
}
if (!existsSync(BASELINE)) {
console.error("check-focus: no baseline. Run with --update to record one.");
process.exit(1);
}
const base = JSON.parse(await readFile(BASELINE, "utf8"));
if (base.runs !== RUNS || base.seeds?.join() !== SEEDS.join()
|| base.seedMode !== SEED_MODE || base.party.join() !== PARTY_KEYS.join()) {
console.error("check-focus: FAILED — the baseline was recorded under different conditions "
+ `(party ${base.party.join(",")}, ${base.runs} runs, seeds ${base.seeds?.join(",")} `
+ `${base.seedMode ?? "shared"}). 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 = NPCS.find(s => base.creatures[s.key]?.gain !== undefined);
if (probe) {
const a = JSON.stringify(measureAt(probe, base.creatures[probe.key].n));
const b = JSON.stringify(measureAt(probe, base.creatures[probe.key].n));
if (a !== b) {
console.error("check-focus: FAILED — the simulation is not deterministic, so an exact "
+ `baseline cannot mean anything. ${probe.key} measured twice gave ${a} and ${b}.`);
process.exit(1);
}
}
}
const problems = [], added = [], removed = [];
let checked = 0, helped = 0, aboveNoise = 0;
for (const spec of NPCS) {
const was = base.creatures[spec.key];
if (!was) { added.push(spec.key); continue; }
if (was.pinned !== undefined) continue; // recorded as unmeasurable; nothing to compare
const now = measureAt(spec, was.n);
checked++;
if (now.gain > 0) helped++;
if (now.gain > now.noise) aboveNoise++;
const same = now.spread === was.spread && now.focus === was.focus && now.gain === was.gain;
if (!same) {
problems.push(` ${spec.key}: ${was.n} of them — spread ${was.spread}% -> ${now.spread}%, `
+ `focus ${was.focus}% -> ${now.focus}%, focus fire worth ${was.gain} -> ${now.gain}`);
} else if (VERBOSE) {
console.log(` ${spec.key.padEnd(24)} ${was.n}x spread ${was.spread}% focus ${was.focus}% +${was.gain}`);
}
}
for (const key of Object.keys(base.creatures)) if (!NPCS.some(s => s.key === key)) removed.push(key);
if (problems.length || added.length || removed.length) {
console.error(`check-focus: FAILED — ${problems.length} creature(s) answer differently than recorded`);
problems.forEach(p => console.error(p));
if (added.length) console.error(` not in the baseline: ${added.join(", ")} — re-record with --update`);
if (removed.length) console.error(` gone from the bestiary: ${removed.join(", ")} — re-record with --update`);
process.exit(1);
}
/* The claim the bestiary makes, restated as a test rather than as prose: concentrating a
round's attacks is supposed to help against every pack whose fight is in doubt. If it
ever stops doing that, the page is wrong and should be rewritten, not re-recorded. */
/* Two different claims, and only one of them is the page's. The bestiary states the
above-noise COUNT from this artifact, so it cannot overstate that however the number
moves — R-264 dropped it from 31 of 31 to 28 of 30 and the page rewrote itself. What
the page does assert outright is that concentrating fire helps, and that is what fails
here. A failure means rewrite the advice; it is not a baseline to re-record. */
if (helped !== checked) {
console.error(`check-focus: FAILED — focus fire helped only ${helped} of ${checked} packs. `
+ `docs/BESTIARY.md advises concentrating fire against every pack whose fight is in `
+ `doubt, and that is no longer true. Rewrite the advice rather than re-recording.`);
process.exit(1);
}
console.log(`check-focus: OK — focus fire measured against ${checked} packs, helps in all of them`
+ `, ${aboveNoise} of them by more than their own noise`);