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