Files
RingBRP/tools/pack-tables.mjs
T
slaguru666andClaude Opus 5 a5c4378521 R-294: EXPOSURE's tables measured into an artifact, and 58.7% was 74.9%
Desk pass 9 found the scenario's most consequential table held by nothing:
~30 sampled figures, no baseline, catchable only by re-running the exact
command. lethality-baseline.json does not cover them — it measures creatures
SOLO against a frozen four-agent party that is not this cast.

Building the measurer found the figures were also wrong.

simulate.mjs's CLI calls runFight with no options, so every published figure
was measured at the default 40-round ceiling — and runFight does not report
truncation, it scores whoever is standing when the loop stops. Measured at
400:

  line.column6      14.8% -> 15.2%    34/2000 truncated
  line.hollow6col2   7.8% ->  8.3%   129
  line.hollow6col3  28.7% -> 30.1%   264
  cut.hollow6       58.7% -> 74.9%   578   <- 29% of runs never finished

Fourteen points on the single most alarming number in the case, and the one
v0.16 added specifically to warn four-player tables. fight-tail learned this
in R-270 and carries an assertUncensored; EXPOSURE's own tables never got
one. The longest fight at cap 400 is 120 rounds and 400 vs 2000 are
identical, so the cap is comfortable rather than merely sufficient.

tools/pack-tables.mjs measures all 17 configs against the DERIVED cast via
castAndCut, using measure()'s exact discipline — one rng threaded through
every run, not a reseed per run, because reseeding is a different stream and
would not reproduce the published table. It refuses to report or record a
truncated sweep.

tools/check-packs.mjs holds the baseline against the game, so the pair is not
a loop: check-cited holds the prose against the record, this holds the record
against the harness. Hard claims read `now` and never `base`: nothing
truncated, the party equals the declared cast, config floor, and more of the
same creature may not make the party safer. Figures compare exactly, since
the runs are deterministic.

Verified by breaking it: a drifted baseline, CAP lowered to 40, and a removed
config each turn it red, and --update refuses outright rather than recording
a truncated sweep. The removed-config test first passed for a bad reason —
MIN_CONFIGS is a floor and 16 clears it — so a dropped-config check was added
and re-tested on a row in no monotonic chain. All files restored
byte-identical after each probe.

EXPOSURE's three tables and the nine prose figures around them are rebuilt
from the artifact with citation markers; the multi-seed stability claims were
re-measured too (the cut is 74.9/72.7/75.0/73.5 across four seeds, not
58.7/60.5/60.3/59.8). CLEAN GROUND v0.20.

npm run check: 20 guards pass.

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

152 lines
8.2 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";
import { NPCS } from "./content.mjs";
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("");
}