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>
152 lines
8.2 KiB
JavaScript
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("");
|
|
}
|