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:
co-authored by
Claude Opus 5
parent
0fa21263b4
commit
e80f7bb564
+1
-1
@@ -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
@@ -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}>>}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user