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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-31 21:17:28 +01:00
co-authored by Claude Opus 5
parent 2d4f7f18be
commit 414dbd7037
+68 -1
View File
@@ -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 <key>[,<key>] [--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 <key> [--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))