Files
RingBRP/tools/effective-attackers.mjs
T
slaguru666andClaude Opus 5 0b86c5ffc3 R-268: guard the sentence that names a player's character
CLEAN GROUND prints that the four-player cut is three effective attackers and
that Ashcroft is not one. Nothing checked it, and a GM reads it aloud to decide
who a real player spends four hours being.

effective-attackers.mjs measures per attack, not per fight: counted per fight
Braithwaite leads on disables, but only because his armour buys him a third more
swings -- per attack he is the weakest of the three. Both rates are recorded and
only the per-attack one is reasoned from. Asserts exact drift, then the sentence:
three clear 5% of attacks disabling, one does not, and that one is Ashcroft.
Re-recording does not silence the claim check; verified in a worktree.

declared-cast.mjs holds the cast marker reading both scenario guards need, rather
than a copy in each. It was briefly named scenario-cast.mjs, which check-scenarios
sweeps into the scenario corpus -- its own example marker was read as a real cast.

update-readme dropped any guard that exited non-zero, so check-rollable vanished
from the README and the count word fell to thirteen while fourteen guards ran. A
missing line is now fatal.

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

200 lines
9.2 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";
import { NPCS } from "./content.mjs";
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`);