diff --git a/package.json b/package.json index 9e6de08..8b4a55f 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/tools/all-specs.mjs b/tools/all-specs.mjs index 8998db1..40380cb 100644 --- a/tools/all-specs.mjs +++ b/tools/all-specs.mjs @@ -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>} diff --git a/tools/check-cited.mjs b/tools/check-cited.mjs index 327f9a3..255cbce 100644 --- a/tools/check-cited.mjs +++ b/tools/check-cited.mjs @@ -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"; diff --git a/tools/detention-baseline.json b/tools/detention-baseline.json new file mode 100644 index 0000000..88abf10 --- /dev/null +++ b/tools/detention-baseline.json @@ -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 + } + } +} diff --git a/tools/detention-tables.mjs b/tools/detention-tables.mjs new file mode 100644 index 0000000..72b1044 --- /dev/null +++ b/tools/detention-tables.mjs @@ -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(""); +} diff --git a/tools/simulate.mjs b/tools/simulate.mjs index 0865901..f1aed36 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -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);