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))