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>
This commit is contained in:
slaguru666
2026-09-16 11:15:38 +01:00
co-authored by Claude Opus 5
parent 0fa21263b4
commit e80f7bb564
6 changed files with 238 additions and 3 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"play": "node tools/playthrough.mjs",
"mj": "node tools/mj-queue.mjs",
"simulate": "node tools/simulate.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"test": "bun run check",
"readme": "bun tools/update-readme.mjs"
},
+1 -1
View File
@@ -16,7 +16,7 @@ import { NPCS, PREGENS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
/** Scenario modules whose casts join the list. */
export const SCENARIOS = ["throughtrain", "starter", "lastadmission", "openday"];
export const SCENARIOS = ["throughtrain", "starter", "lastadmission", "openday", "standingorder"];
/**
* @returns {Promise<Array<{label: string, kind: "agent"|"npc", spec: object}>>}
+1
View File
@@ -54,6 +54,7 @@ const ARTIFACTS = {
"lethality": "tools/lethality-baseline.json",
"outcomes": "tools/outcome-coverage-baseline.json",
"packs": "tools/pack-tables-baseline.json",
"detention": "tools/detention-baseline.json",
"step5": { derive: step5Split, label: "tools/step5-split.mjs, enumerated on this build" }
};
const isDerived = name => typeof ARTIFACTS[name] === "object";
+66
View File
@@ -0,0 +1,66 @@
{
"note": "Generated by tools/detention-tables.mjs --update. Do not edit by hand.",
"runs": 2000,
"seed": 11,
"cap": 400,
"party": {
"line": [
"holloway",
"kowalczyk",
"renshaw",
"bhattacharya",
"nkemdirim",
"okonkwo"
],
"cut": [
"holloway",
"kowalczyk",
"renshaw",
"bhattacharya"
]
},
"line": {
"detail": {
"label": "Teague and three wardens",
"n": 4,
"hurt": 2.71,
"down": 1.16,
"wiped": 0.3,
"deaths": 0.59,
"median": 5,
"capped": 0
},
"full": {
"label": "Teague and five wardens",
"n": 6,
"hurt": 4.75,
"down": 3.51,
"wiped": 15.7,
"deaths": 2.07,
"median": 10,
"capped": 0
}
},
"cut": {
"pair": {
"label": "Teague and one warden",
"n": 2,
"hurt": 1.43,
"down": 0.54,
"wiped": 4,
"deaths": 0.23,
"median": 4,
"capped": 0
},
"detail": {
"label": "Teague and three wardens",
"n": 4,
"hurt": 3.5,
"down": 2.89,
"wiped": 60.3,
"deaths": 1.31,
"median": 7,
"capped": 0
}
}
}
+165
View File
@@ -0,0 +1,165 @@
/**
* 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("");
}
+4 -1
View File
@@ -55,6 +55,7 @@ import {
} from "../rules.mjs";
import { combatPowerFor } from "./powers.mjs";
import { WEAPONS, ARMOURS, NPCS, PREGENS } from "./content.mjs";
import { SCENARIOS } from "./all-specs.mjs";
import { ROSTER } from "./roster.mjs";
import { expandFromRegister } from "./expand-spec.mjs";
import { pathToFileURL } from "node:url";
@@ -513,7 +514,9 @@ export function measure(partySpecs, enemySpecs, { runs = 400, seed = 1 } = {}) {
const ALL_SPECS = new Map();
for (const s of [...NPCS, ...PREGENS, ...ROSTER]) ALL_SPECS.set(s.key, s);
for (const name of ["throughtrain", "starter", "lastadmission", "openday"]) {
/* The scenario list is all-specs.mjs's, so a new scenario's cast is fightable here the day
it is registered there, rather than the day somebody finds this second copy. */
for (const name of SCENARIOS) {
const mod = await import(`./scenario-${name}.mjs`);
for (const v of Object.values(mod)) {
if (Array.isArray(v)) for (const s of v) if (s?.ch && s.key) ALL_SPECS.set(s.key, s);