Files
RingBRP/tools/effective-attackers.mjs
T
slaguru666andClaude Opus 5 8dbe885a34 Add check-seam: one definition of the NPC population
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>
2026-09-22 23:52:18 +01:00

204 lines
9.5 KiB
JavaScript

/**
* effective-attackers — what each of the four-player cut actually contributes.
*
* CLEAN GROUND prints "the four-player cut is three effective attackers, not four.
* Ashcroft is not one." That is a sentence about a fight, sitting in a document, with
* nothing checking it — the arrangement this project has now been caught by four times.
* Re-kit Ashcroft, or change what the column dodges at, and the warning becomes false
* silently while the page keeps printing it.
*
* node tools/effective-attackers.mjs read the recorded figures
* node tools/effective-attackers.mjs --check compare against the baseline
* node tools/effective-attackers.mjs --update re-record it
*
* MEASURED PER ATTACK, NOT PER FIGHT, and the distinction is the finding. Counted per
* fight, Braithwaite takes 36% of the cut's disables and looks like its best attacker.
* He is its most ARMOURED: he is downed in a quarter of fights where the others are
* downed in a third, so he gets a third more swings, and per attack he is the weakest of
* the three. A rate that rewards survival is the confound R-259 was caught by, so both
* are recorded here and only the per-attack figures are reasoned from.
*/
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", "attackers-baseline.json");
const argv = process.argv.slice(2);
const UPDATE = argv.includes("--update");
const CHECK = argv.includes("--check");
const RUNS = 2000;
const SEEDS = [11, 4242, 90210];
const CREATURE = "quiet_neighbours_npc";
const COUNT = 3;
/* The bar the document's sentence implies. Set where the measurement leaves a gap of two
orders of magnitude — the three land a disabling blow on 11 to 14 per cent of their
attacks and Ashcroft on 0.1 — so it tests the claim rather than the sampling. */
const EFFECTIVE = 5;
const { cut: CUT } = castAndCut("effective-attackers");
const party = CUT.map(k => {
const found = ROSTER.find(r => r.key === k || r.key === "pc_" + k);
if (!found) { console.error(`effective-attackers: "${k}" is not on the duty roster`); process.exit(1); }
return found;
});
const spec = NPCS.find(n => n.key === CREATURE);
if (!spec) { console.error(`effective-attackers: no creature keyed "${CREATURE}"`); process.exit(1); }
const foes = Array(COUNT).fill(spec);
const mean = a => a.reduce((x, y) => x + y, 0) / a.length;
const r1 = n => Number(n.toFixed(1));
/** One sweep: every attack each agent makes, and every time they are put down. */
function sweep(seed, runs) {
const t = {};
for (let i = 0; i < runs; i++) {
const downedThisFight = new Set();
runFight(makeRng(seed + i * 2654435761), party, foes, { say: e => {
if (e.kind === "hit" && e.defender.side === "party" && e.defender.hp <= 0
&& !downedThisFight.has(e.defender.name)) {
downedThisFight.add(e.defender.name);
(t[e.defender.name] ??= blank()).downed++;
}
if (!["hit", "miss", "dodged", "stopped"].includes(e.kind)) return;
if (e.attacker?.side !== "party") return;
const a = t[e.attacker.name] ??= blank();
a.attacks++;
if (e.kind === "hit") {
a.hits++; a.damage += e.taken;
if (e.disabled || e.destroyed || e.defender.hp <= 0) a.disables++;
}
}});
}
return t;
}
const blank = () => ({ attacks: 0, hits: 0, damage: 0, disables: 0, downed: 0 });
function measure(runs = RUNS) {
const sweeps = SEEDS.map(s => sweep(seedFor(s, "attackers"), runs));
const agents = {};
for (const p of party) {
const rows = sweeps.map(t => t[p.name] ?? blank());
const landed = rows.map(a => (100 * a.hits) / Math.max(a.attacks, 1));
agents[p.key.replace(/^pc_/, "")] = {
name: p.name,
attacksPerFight: r1(mean(rows.map(a => a.attacks / runs))),
landed: r1(mean(landed)),
damagePerHit: r1(mean(rows.map(a => a.damage / Math.max(a.hits, 1)))),
disablesPerAttack: r1(mean(rows.map(a => (100 * a.disables) / Math.max(a.attacks, 1)))),
downedInPctOfFights: r1(mean(rows.map(a => (100 * a.downed) / runs))),
landedNoise: r1(Math.max(...landed) - Math.min(...landed))
};
}
return agents;
}
if (UPDATE) {
await writeFile(BASELINE, JSON.stringify({
note: "Generated by tools/effective-attackers.mjs --update. Do not edit by hand.",
runs: RUNS, seeds: SEEDS, creature: CREATURE, count: COUNT,
effectiveBar: EFFECTIVE, cast: CUT, agents: measure()
}, null, 2) + "\n", "utf8");
console.log(`effective-attackers: baseline recorded — ${CUT.length} agents, ${RUNS} runs x ${SEEDS.length} seeds`);
process.exit(0);
}
if (!existsSync(BASELINE)) {
console.error("effective-attackers: no baseline. Run with --update to record one.");
process.exit(1);
}
const base = JSON.parse(await readFile(BASELINE, "utf8"));
if (!CHECK) {
console.log(`\nthe four-player cut, per attack — ${base.runs} runs x ${base.seeds.length} seeds\n`);
for (const [key, a] of Object.entries(base.agents).sort((x, y) => y[1].disablesPerAttack - x[1].disablesPerAttack)) {
console.log(` ${a.name.padEnd(18)} ${a.attacksPerFight} attacks/fight · lands ${a.landed}% `
+ `· ${a.damagePerHit} damage per hit · ${a.disablesPerAttack}% of attacks disable `
+ `· downed in ${a.downedInPctOfFights}% of fights`);
}
console.log(`\n Counted per fight instead, the order changes: the most armoured agent gets the`);
console.log(` most swings and takes the most disables while being the weakest per attack.\n`);
process.exit(0);
}
/* ---- --check ------------------------------------------------------------------ */
if (base.runs !== RUNS || base.seeds?.join() !== SEEDS.join()
|| base.creature !== CREATURE || base.count !== COUNT) {
console.error(`effective-attackers: FAILED — the baseline was recorded under different `
+ `conditions (${base.runs} runs, seeds ${base.seeds?.join(",")}, ${base.count} x ${base.creature}). `
+ `Re-record it with --update.`);
process.exit(1);
}
if (base.cast.join() !== CUT.join()) {
const gone = base.cast.filter(k => !CUT.includes(k));
const added = CUT.filter(k => !base.cast.includes(k));
console.error(`effective-attackers: FAILED — the scenario's four-player cut is different people `
+ (gone.length ? `— no longer ${gone.join(", ")}` : "— the same four, reordered")
+ (added.length ? `, now ${added.join(", ")}` : "")
+ `. Re-record with --update; the warning in CLEAN GROUND names an agent and may now name `
+ `the wrong one.`);
process.exit(1);
}
/* Exact comparison is only honest if the measurement is exact. Prove it, don't assume. */
{
const a = JSON.stringify(measure(150));
const b = JSON.stringify(measure(150));
if (a !== b) {
console.error("effective-attackers: FAILED — the simulation is not deterministic, so an exact "
+ "baseline cannot mean anything.");
process.exit(1);
}
}
const now = measure();
const problems = [];
for (const [key, was] of Object.entries(base.agents)) {
const m = now[key];
if (!m) { problems.push(` ${key}: no longer in the cut`); continue; }
for (const f of ["attacksPerFight", "landed", "damagePerHit", "disablesPerAttack", "downedInPctOfFights"]) {
if (m[f] !== was[f]) problems.push(` ${was.name}: ${f} ${was[f]} -> ${m[f]}`);
}
}
if (problems.length) {
console.error(`effective-attackers: FAILED — the cut no longer fights the way it was recorded`);
problems.forEach(p => console.error(p));
process.exit(1);
}
/* The claim CLEAN GROUND prints, restated as a test: three of the four are attackers and
one is not, and the one who is not is Ashcroft. The drift comparison above already
catches every movement, so this asserts only the sentence — a guard that pinned the
exact rates would fail on news rather than on error (R-261). */
const effective = Object.entries(now).filter(([, a]) => a.disablesPerAttack >= EFFECTIVE);
const ineffective = Object.entries(now).filter(([, a]) => a.disablesPerAttack < EFFECTIVE);
if (effective.length !== 3 || ineffective.length !== 1) {
console.error(`effective-attackers: FAILED — ${effective.length} of the cut's ${CUT.length} clear `
+ `${EFFECTIVE}% of attacks disabling. CLEAN GROUND's scaling note says three effective `
+ `attackers and one who is not; that sentence is now wrong. Rewrite the warning rather `
+ `than re-recording.`);
process.exit(1);
}
if (ineffective[0][0] !== "ashcroft") {
console.error(`effective-attackers: FAILED — the agent who is not an attacker is now `
+ `${ineffective[0][1].name}, and CLEAN GROUND's warning names Ashcroft. Rewrite the warning, `
+ `which names the wrong person.`);
process.exit(1);
}
const a = now.ashcroft;
console.log(`effective-attackers: OK — three of the cut disable on ${Math.min(...effective.map(([, x]) => x.disablesPerAttack))}% `
+ `of their attacks or better, and Ashcroft on ${a.disablesPerAttack}% across ${a.attacksPerFight} attacks a fight`);