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>
200 lines
9.2 KiB
JavaScript
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`);
|