/** * 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(""); }