Files
RingBRP/tools/detention-tables.mjs
slaguru666andClaude Opus 5 e80f7bb564 detention-tables: measure STANDING ORDER's Act Two fight, and hold it
A second scenario gets pack-tables' arrangement: four configs (Teague plus
one, three or five wardens) against the declared six and the four-player
cut, 2000 runs at seed 11, cap 400, recorded to detention-baseline.json,
citeable as "detention", and --check added to npm run check.

standingorder joins all-specs' SCENARIOS, and simulate.mjs now reads that
list instead of keeping its own copy, so a new cast is fightable the day it
is registered. The lethality, focus and pack baselines are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:15:38 +01:00

166 lines
7.9 KiB
JavaScript

/**
* STANDING ORDER's detention fight, measured into an artifact instead of typed into prose.
*
* The Controller sends the wardens in Act Two, and the GM needs to know before it happens
* how likely the party is to end up in the Sump. This is pack-tables.mjs's arrangement for
* a second scenario: the configs are the rows the document prints, the party is the cast
* the document declares, and a citation addresses one figure.
*
* node tools/detention-tables.mjs print the table
* node tools/detention-tables.mjs --update re-record tools/detention-baseline.json
* node tools/detention-tables.mjs --check hold the record against the game (in npm run check)
*
* NOT named scenario-something: check-scenarios sweeps `tools/scenario-*` into the scenario
* corpus, and this file's prose is not a scenario's.
*
* WHAT THE FIGURES ARE. runFight does not model restraint. A warden in the document stops
* hitting an agent who is down and cuffs them; in the harness he keeps swinging. So `wiped`
* is "the whole party detained" and is sound, while `deaths` is a CEILING — the fight with
* nobody holding back and nobody treating the fallen — and the document must say so beside
* any death figure it prints.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import path from "node:path";
import { ROSTER } from "./roster.mjs";
import { runFight, makeRng } from "./simulate.mjs";
import { castMarkersIn } from "./declared-cast.mjs";
import { SO_CAST } from "./scenario-standingorder.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const DOC = path.join(ROOT, "docs", "scenarios", "STANDING_ORDER.md");
export const BASELINE = path.join(ROOT, "tools", "detention-baseline.json");
export const RUNS = 2000;
export const SEED = 11;
export const CAP = 400;
const FIELDS = ["hurt", "down", "wiped", "deaths", "median"];
/* The document's scaling note drops these two at four players. Named, not sliced. */
const DROPPED_AT_FOUR = ["okonkwo", "nkemdirim"];
function castAndCut() {
const markers = castMarkersIn(readFileSync(DOC, "utf8"));
if (markers.length !== 1) {
console.error(`detention-tables: STANDING_ORDER.md carries ${markers.length} cast markers; it must carry exactly one.`);
process.exit(1);
}
const line = markers[0].keys.split(",").map(x => x.trim().replace(/^pc_/, "")).filter(Boolean);
const cut = line.filter(k => !DROPPED_AT_FOUR.includes(k));
if (line.length !== 6 || cut.length !== 4) {
console.error(`detention-tables: the declared cast is ${line.length} and its cut ${cut.length}; `
+ `the recorded fights are against six and four. Re-read the scaling note and re-record.`);
process.exit(1);
}
return { line, cut };
}
const agent = k => {
const a = ROSTER.find(r => r.key === `pc_${k}`);
if (!a) { console.error(`detention-tables: the declared cast names "${k}", who is not on the roster.`); process.exit(1); }
return a;
};
const foe = k => {
const c = SO_CAST.find(n => n.key === k);
if (!c) { console.error(`detention-tables: no cast member keyed "${k}" in scenario-standingorder.mjs.`); process.exit(1); }
return c;
};
/** Every row the document prints. Teague always leads; the detail grows with the table. */
export const CONFIGS = [
{ party: "cut", id: "pair", label: "Teague and one warden", force: [["so_teague", 1], ["so_warden", 1]] },
{ party: "cut", id: "detail", label: "Teague and three wardens", force: [["so_teague", 1], ["so_warden", 3]] },
{ party: "line", id: "detail", label: "Teague and three wardens", force: [["so_teague", 1], ["so_warden", 3]] },
{ party: "line", id: "full", label: "Teague and five wardens", force: [["so_teague", 1], ["so_warden", 5]] }
];
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],
capped
};
}
export function measureAll() {
const { line, cut } = castAndCut();
const parties = { line: line.map(agent), cut: cut.map(agent) };
const out = { note: "Generated by tools/detention-tables.mjs --update. Do not edit by hand.",
runs: RUNS, seed: SEED, cap: CAP, party: { line, cut }, line: {}, 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;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const argv = process.argv.slice(2);
const m = measureAll();
const capped = CONFIGS.filter(c => m[c.party][c.id].capped > 0).map(c => `${c.party}.${c.id}`);
if (capped.length) {
console.error(`detention-tables: FAILED — ${capped.join(", ")} hit the ${CAP}-round ceiling; `
+ `a truncated fight understates the detention rate. Raise CAP.`);
process.exit(1);
}
if (argv.includes("--update")) {
writeFileSync(BASELINE, JSON.stringify(m, null, 2) + "\n");
console.log(`detention-tables: recorded ${path.relative(ROOT, BASELINE)} — ${CONFIGS.length} configs, `
+ `${RUNS} runs, seed ${SEED}, nothing truncated`);
process.exit(0);
}
if (argv.includes("--check")) {
if (!existsSync(BASELINE)) {
console.error("detention-tables: FAILED — no baseline. Record one with --update.");
process.exit(1);
}
const base = JSON.parse(readFileSync(BASELINE, "utf8"));
const problems = [];
for (const which of ["line", "cut"]) {
if (base.party?.[which]?.join(",") !== m.party[which].join(",")) {
problems.push(` the ${which} party is now ${m.party[which].join(", ")}, recorded against ${base.party?.[which]?.join(", ")}.`);
}
}
for (const c of CONFIGS) {
const b = base[c.party]?.[c.id], n = m[c.party][c.id];
if (!b) { problems.push(` ${c.party}.${c.id} is measured and not recorded.`); continue; }
for (const f of FIELDS) if (b[f] !== n[f]) problems.push(` ${c.party}.${c.id}.${f}: the game says ${n[f]}, the record holds ${b[f]}.`);
}
for (const which of ["line", "cut"])
for (const id of Object.keys(base[which] ?? {}))
if (!CONFIGS.some(c => c.party === which && c.id === id)) problems.push(` ${which}.${id} is recorded and no longer measured.`);
if (problems.length) {
console.error("detention-tables: FAILED — the record no longer describes the fight the game runs. "
+ "Deterministic at the seed, so this is a real change; re-record with --update and fix the prose, "
+ "which check-cited will insist on.");
problems.forEach(p => console.error(p));
process.exit(1);
}
console.log(`detention-tables: OK — ${CONFIGS.length} detention fights, ${RUNS} runs each at seed ${SEED}, `
+ `nothing truncated, every figure matching the record`);
process.exit(0);
}
for (const which of ["cut", "line"]) {
console.log(`\n ${which === "line" ? "the declared six" : "the four-player cut"} — ${m.party[which].join(", ")}\n`);
console.log(" detail hurt down detained deaths(ceiling) median");
for (const c of CONFIGS.filter(x => x.party === which)) {
const r = m[which][c.id];
console.log(` ${c.label.padEnd(30)} ${String(r.hurt).padStart(5)} ${String(r.down).padStart(5)} `
+ `${(r.wiped + "%").padStart(9)} ${String(r.deaths).padStart(16)} ${String(r.median).padStart(7)}`);
}
}
console.log("");
}