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>
156 lines
8.5 KiB
JavaScript
156 lines
8.5 KiB
JavaScript
/**
|
|
* EXPOSURE's tables, measured into an artifact instead of typed into prose.
|
|
*
|
|
* CLEAN GROUND's most consequential table is the one that decides whether a GM lets a fight
|
|
* happen, and until now it was about thirty sampled figures held by nothing. Desk pass 9
|
|
* counted the surface: 101 percentages in that document, 67 of them computed statistics, 14
|
|
* cited. `lethality-baseline.json` does NOT cover these — it measures each creature SOLO
|
|
* against a frozen four-agent party that is not this scenario's cast, and stores
|
|
* {wipe, down, rounds}. These are packs of three to fifteen against the declared six, or the
|
|
* four-player cut, and they are a different measurement of a different fight.
|
|
*
|
|
* node tools/pack-tables.mjs print the tables
|
|
* node tools/pack-tables.mjs --json the artifact, to stdout
|
|
* node tools/pack-tables.mjs --update re-record tools/pack-tables-baseline.json
|
|
*
|
|
* THE CAP IS THE WHOLE REASON THIS FILE EXISTS AT THE SIZE IT DOES. `simulate.mjs`'s CLI
|
|
* calls runFight with no options, so every published figure was measured at the default
|
|
* maxRounds of 40 — and runFight does not report truncation, it scores whoever is standing
|
|
* when the loop stops. Measured at 400: six hollow men against the four-player cut hit the
|
|
* 40-round ceiling in 578 runs of 2000, and the party's true wipe rate is 74.9%, not the
|
|
* 58.7% the document printed. Fourteen points, on the single most alarming number in the
|
|
* case, produced by a ceiling nobody had looked at. fight-tail learned this in R-270 and
|
|
* carried an `assertUncensored`; EXPOSURE's own tables never got one. They have one now.
|
|
*
|
|
* The longest fight at CAP 400 is 120 rounds, and 400 and 2000 give identical figures, so
|
|
* the cap is comfortable rather than merely sufficient.
|
|
*/
|
|
import { writeFileSync } 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, 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")), "..");
|
|
export const BASELINE = path.join(ROOT, "tools", "pack-tables-baseline.json");
|
|
|
|
export const RUNS = 2000;
|
|
export const SEED = 11;
|
|
/* Same ceiling as fight-tail, for the same reason and by the same argument. */
|
|
export const CAP = 400;
|
|
|
|
const { line: LINE, cut: CUT } = castAndCut("pack-tables");
|
|
const agent = k => {
|
|
const a = ROSTER.find(r => r.key === `pc_${k}`);
|
|
if (!a) { console.error(`pack-tables: the declared cast names "${k}", who is not on the roster.`); process.exit(1); }
|
|
return a;
|
|
};
|
|
const foe = k => {
|
|
const c = NPCS.find(n => n.key === k);
|
|
if (!c) { console.error(`pack-tables: no creature keyed "${k}" in content.mjs.`); process.exit(1); }
|
|
return c;
|
|
};
|
|
|
|
/** Every row EXPOSURE prints, keyed so a citation can address one figure. */
|
|
export const CONFIGS = [
|
|
{ party: "line", id: "understudy", label: "the understudy, alone", force: [["the_understudy", 1]] },
|
|
{ party: "line", id: "duplicate", label: "a duplicate of an agent", force: [["duplicate", 1]] },
|
|
{ party: "line", id: "hollow1", label: "one hollow man", force: [["hollow_man", 1]] },
|
|
{ party: "line", id: "hollow3", label: "three hollow men", force: [["hollow_man", 3]] },
|
|
{ party: "line", id: "hollow6", label: "six hollow men", force: [["hollow_man", 6]] },
|
|
{ party: "line", id: "column3", label: "three of the column", force: [["quiet_neighbours_npc", 3]] },
|
|
{ party: "line", id: "column6", label: "six of the column", force: [["quiet_neighbours_npc", 6]] },
|
|
{ party: "line", id: "column10", label: "ten of the column", force: [["quiet_neighbours_npc", 10]] },
|
|
{ party: "line", id: "column15", label: "fifteen of the column", force: [["quiet_neighbours_npc", 15]] },
|
|
{ party: "line", id: "hollow6col2", label: "six hollow men + two of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 2]] },
|
|
{ party: "line", id: "hollow6col3", label: "six hollow men + three of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 3]] },
|
|
{ party: "line", id: "hollow6col6", label: "six hollow men + six of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 6]] },
|
|
{ party: "cut", id: "hollow3", label: "three hollow men", force: [["hollow_man", 3]] },
|
|
{ party: "cut", id: "hollow6", label: "six hollow men", force: [["hollow_man", 6]] },
|
|
{ party: "cut", id: "hollow6col2", label: "six hollow men + two of the column", force: [["hollow_man", 6], ["quiet_neighbours_npc", 2]] },
|
|
{ party: "cut", id: "column3", label: "three of the column", force: [["quiet_neighbours_npc", 3]] },
|
|
{ party: "cut", id: "column6", label: "six of the column", force: [["quiet_neighbours_npc", 6]] }
|
|
];
|
|
|
|
/**
|
|
* measure()'s exact discipline — ONE rng threaded through every run, not a reseed per run.
|
|
* Reseeding per run is a different stream and gives different figures; the published table
|
|
* came out of the continuous one, and a baseline that cannot reproduce the document is a
|
|
* baseline that will be argued with rather than believed.
|
|
*/
|
|
function sweep(party, foes) {
|
|
const rng = makeRng(SEED);
|
|
const rounds = [];
|
|
let hurt = 0, down = 0, wipes = 0, dead = 0, capped = 0;
|
|
for (let i = 0; i < RUNS; i++) {
|
|
const r = runFight(rng, party, foes, { maxRounds: CAP });
|
|
rounds.push(r.rounds);
|
|
hurt += r.partyHurt; down += r.partyDown; dead += r.dead;
|
|
if (r.wiped) wipes++;
|
|
if (r.rounds >= CAP) capped++;
|
|
}
|
|
rounds.sort((a, b) => a - b);
|
|
return {
|
|
hurt: Number((hurt / RUNS).toFixed(2)),
|
|
down: Number((down / RUNS).toFixed(2)),
|
|
wiped: Number((100 * wipes / RUNS).toFixed(1)),
|
|
deaths: Number((dead / RUNS).toFixed(2)),
|
|
median: rounds[RUNS >> 1],
|
|
longest: rounds[RUNS - 1],
|
|
capped
|
|
};
|
|
}
|
|
|
|
export function measureAll() {
|
|
const parties = { line: LINE.map(agent), cut: CUT.map(agent) };
|
|
const out = { note: "Generated by tools/pack-tables.mjs --update. Do not edit by hand.",
|
|
runs: RUNS, seed: SEED, cap: CAP, line: {}, cut: {} };
|
|
out.party = { line: LINE, cut: CUT };
|
|
for (const c of CONFIGS) {
|
|
const foes = c.force.flatMap(([k, n]) => Array(n).fill(foe(k)));
|
|
out[c.party][c.id] = { label: c.label, n: foes.length, ...sweep(parties[c.party], foes) };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Nothing may touch the ceiling. A censored run is scored by who is standing when the loop
|
|
stops, so a truncated sweep understates the wipe rate and says nothing about it. */
|
|
export function censoredIn(m) {
|
|
return CONFIGS.filter(c => m[c.party][c.id].capped > 0)
|
|
.map(c => `${c.party}.${c.id} (${m[c.party][c.id].capped}/${m.runs})`);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const argv = process.argv.slice(2);
|
|
const m = measureAll();
|
|
const bad = censoredIn(m);
|
|
if (bad.length) {
|
|
console.error(`pack-tables: REFUSING to report — ${bad.length} config(s) hit the ${CAP}-round `
|
|
+ `ceiling: ${bad.join(", ")}. Raise CAP and re-run; a truncated fight is scored by who is `
|
|
+ `standing when the loop stops, which understates the wipe rate silently.`);
|
|
process.exit(1);
|
|
}
|
|
if (argv.includes("--json")) { console.log(JSON.stringify(m, null, 2)); process.exit(0); }
|
|
if (argv.includes("--update")) {
|
|
writeFileSync(BASELINE, JSON.stringify(m, null, 2) + "\n");
|
|
console.log(`pack-tables: recorded ${path.relative(ROOT, BASELINE)} — ${CONFIGS.length} configs, `
|
|
+ `${RUNS} runs, seed ${SEED}, cap ${CAP}, nothing truncated`);
|
|
process.exit(0);
|
|
}
|
|
for (const which of ["line", "cut"]) {
|
|
console.log(`\n ${which === "line" ? `the declared six — ${LINE.join(", ")}` : `the four-player cut — ${CUT.join(", ")}`}\n`);
|
|
console.log(" what they fight hurt down wiped deaths median longest");
|
|
for (const c of CONFIGS.filter(x => x.party === which)) {
|
|
const r = m[which][c.id];
|
|
console.log(` ${c.label.padEnd(38)} ${String(r.hurt).padStart(5)} ${String(r.down).padStart(5)} `
|
|
+ `${(r.wiped + "%").padStart(7)} ${String(r.deaths).padStart(7)} ${String(r.median).padStart(7)} ${String(r.longest).padStart(8)}`);
|
|
}
|
|
}
|
|
console.log("");
|
|
}
|