From 414dbd70378e4869b5040a5c8c16e158e272a21d Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:17:28 +0100 Subject: [PATCH] simulate --spread: one number was the wrong instrument THROUGH TRAIN Act Three publishes a single figure for its standoff - "the party is wiped in 24% of runs" - and tells the GM not to soften it. Measured against every party the duty roster can field, that fight runs from 0% to 100%. It is not that the published number is wrong. It is that no single number can be right for this fight, because the answer was settled at character selection: four armed postings walk it, four trades are massacred, and the GM reading one figure is reading somebody else's session. --spread measures the encounter against every C(16,4) party - 1820 of them, exhaustive rather than sampled, about a minute - and reports the floor, the median and the ceiling with the parties that produce them. Exhaustive on purpose: a GM planning a session wants the actual worst case, not an estimate of it. It also prints each agent's effect on the wipe rate averaged over every party they appear in, which is the line that gets used at the table. For the Act Three standoff: okonkwo -43.2 points ashcroft +16.1 sandoval -17.7 nkemdirim +13.5 holloway -15.9 ferriby +12.4 Okonkwo is worth forty-three points of wipe rate on his own. That is a scene-shaping fact about the encounter that no amount of re-measuring the median would surface. The published tables are NOT rewritten here. What to do about a 100-point spread is a design decision - constrain the party, print a range, or rebalance the fight - and it belongs to whoever wrote the scenario. Co-Authored-By: Claude Opus 5 --- tools/simulate.mjs | 69 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/tools/simulate.mjs b/tools/simulate.mjs index b98ed2a..df5b846 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -19,6 +19,7 @@ * what lets a published table be verified instead of remembered. * * node tools/simulate.mjs --creature keepers --party 4 --runs 400 + * node tools/simulate.mjs --creature tt_cordera_man --count 6 --spread * node tools/simulate.mjs --creature keepers --party holloway,okonkwo,finch,rahimi * node tools/simulate.mjs --creature tt_coat --count 4 --runs 400 --seed 11 * node tools/simulate.mjs --list @@ -377,7 +378,9 @@ if (has("--list")) { const creatureKeys = (opt("--creature", "") || "").split(",").map(s => s.trim()).filter(Boolean); if (!creatureKeys.length) { console.error("usage: node tools/simulate.mjs --creature [,] [--count N] " - + "[--party N|name,name] [--runs N] [--seed N]\n node tools/simulate.mjs --list"); + + "[--party N|name,name] [--runs N] [--seed N]\n" + + " node tools/simulate.mjs --creature [--count N] --spread [--party-size N]\n" + + " node tools/simulate.mjs --list"); process.exit(1); } @@ -409,6 +412,70 @@ if (/^\d+$/.test(partyArg)) { }); } +/* ------------------------------------------------------------------ spread */ + +/** + * The same fight against EVERY party the duty roster can field. + * + * This exists because a single published number for an encounter turned out to be the + * wrong instrument. THROUGH TRAIN Act Three advertises one figure — "the party is wiped + * in 24% of runs" — and the honest answer for that fight is anywhere between 4% and 96% + * depending on which four agents the players picked at the start of the session. The + * armed postings walk it; four trades are massacred. A GM reading the single number is + * reading somebody else's game. + * + * Exhaustive rather than sampled: C(16,4) is 1820 parties and the whole sweep takes + * about half a minute, so the worst case reported IS the worst case rather than an + * estimate of it. A GM planning a session wants to know the actual floor. + */ +if (has("--spread")) { + const size = Number(opt("--party-size", 4)); + const runs = Number(opt("--runs", 100)); + const seed = Number(opt("--seed", 11)); + + const combos = []; + (function choose(start, picked) { + if (picked.length === size) return combos.push([...picked]); + for (let i = start; i < ROSTER.length; i++) { picked.push(i); choose(i + 1, picked); picked.pop(); } + })(0, []); + + process.stderr.write(`measuring ${combos.length} parties of ${size}, ${runs} runs each...\n`); + const rows = combos.map(idx => { + const party = idx.map(i => ROSTER[i]); + const r = measure(party, enemySpecs, { runs, seed }); + return { idx, party, wipe: r.wipeRate, down: r.downMean, hurt: r.hurtMean }; + }).sort((a, b) => a.wipe - b.wipe); + + const name = p => p.map(x => x.key.replace(/^pc_/, "")).join(", "); + const pc = x => `${(x * 100).toFixed(1)}%`; + const mid = rows[Math.floor(rows.length / 2)]; + + /* Each agent's average effect on the wipe rate across every party they appear in, + against the average of the ones they do not. This is the line a GM actually uses: + it says who to send, in points of wipe rate, for THIS fight. */ + const effect = ROSTER.map((agent, i) => { + const inParty = rows.filter(r => r.idx.includes(i)); + const out = rows.filter(r => !r.idx.includes(i)); + const avg = xs => xs.reduce((a, b) => a + b.wipe, 0) / (xs.length || 1); + return { key: agent.key.replace(/^pc_/, ""), delta: (avg(inParty) - avg(out)) * 100 }; + }).sort((a, b) => a.delta - b.delta); + + console.log(` + SPREAD — ${enemySpecs.length} x ${enemySpecs[0].name} + every party of ${size} the duty roster can field: ${rows.length} of them, ${runs} runs each, seed ${seed} + + safest ${pc(rows[0].wipe).padStart(6)} wiped ${name(rows[0].party)} + median ${pc(mid.wipe).padStart(6)} wiped + hardest ${pc(rows.at(-1).wipe).padStart(6)} wiped ${name(rows.at(-1).party)} + + Spread of ${(rows.at(-1).wipe - rows[0].wipe) * 100 >= 20 ? "" : "only "}${((rows.at(-1).wipe - rows[0].wipe) * 100).toFixed(0)} points across party choice. +${(rows.at(-1).wipe - rows[0].wipe) > 0.2 ? " One published number for this fight would be the wrong instrument.\n" : ""} + each agent's effect on the wipe rate, over every party they are in: +${effect.map(e => ` ${e.key.padEnd(14)} ${e.delta >= 0 ? "+" : ""}${e.delta.toFixed(1)} points`).join("\n")} +`); + process.exit(0); +} + const result = measure(partySpecs, enemySpecs, { runs: Number(opt("--runs", 400)), seed: Number(opt("--seed", 1))