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>
499 lines
21 KiB
JavaScript
499 lines
21 KiB
JavaScript
/**
|
|
* The lethality harness — how dangerous is this creature, actually.
|
|
*
|
|
* THROUGH TRAIN Act Three publishes "MEASURED, 400 runs each, four duty-roster agents
|
|
* against the six" and a 24% wipe rate. The starter publishes its own table. Nothing in
|
|
* this repository could produce either number: whatever measured them was never
|
|
* committed, so both are claims rather than results and neither can be re-checked after
|
|
* a rule changes. That is the gap this closes.
|
|
*
|
|
* THE ONE RULE THIS FILE FOLLOWS: it does not know any rules. Every number comes out of
|
|
* rules.mjs — banding, difficulty, graded defences, hit points, major wounds, damage
|
|
* modifiers, Reaction, the dying clock. A harness with its own copy of the combat loop
|
|
* measures a game nobody is playing, and it drifts silently the first time a rule moves.
|
|
* If a rule is missing here, the fix is to export it from rules.mjs, not to write it
|
|
* again. applyDifficulty, resolveBands and gradeRoll were moved out of ringbrp.mjs for
|
|
* exactly this reason — node cannot import the engine, and the engine had the only copy.
|
|
*
|
|
* Deterministic throughout: same creature, same party, same seed, same numbers. That is
|
|
* 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
|
|
*
|
|
* WHAT IT MODELS, AND WHAT IT DOES NOT. It runs Reaction order re-rolled every round,
|
|
* attack banding with the 1% floor and the 96-99 rule, one defence per incoming blow at
|
|
* the stacking defence penalty, graded defences, damage by band with armour ignored on a
|
|
* critical and halved on a special, major wounds, and the dying clock.
|
|
*
|
|
* It also, now, models HIT LOCATIONS — 1d20 against the defender's own species table,
|
|
* melee finding limbs and shooting finding centre of mass, each location carrying its
|
|
* own share of the pool, disabled at its maximum and destroyed at twice it. The
|
|
* consequences apply: a destroyed head is unconscious immediately, a ruined arm costs
|
|
* you every attack, a ruined leg costs you every dodge, and a Cadence losing a body
|
|
* costs the whole creature 10% of everything it does.
|
|
*
|
|
* That last one is why this was worth doing. A Cadence has no vital and no head, so it
|
|
* cannot be dropped by a lucky shot and instead degrades toward useless; measured
|
|
* without locations it was simply a person with an odd portrait, and the number the
|
|
* harness printed was about a creature nobody was playing.
|
|
*
|
|
* It still does NOT model bleeding, panic, Coherence, cover, range bands, fire modes or
|
|
* burst. Those all exist in rules.mjs, they all matter, and every one of them makes a
|
|
* fight WORSE for whoever is losing. Read the output as a floor, never as a ceiling.
|
|
*/
|
|
import {
|
|
hitPointsFor, majorWoundFor, damageModifierFor, reactionBaseFrom,
|
|
resolveBands, gradeRoll, defenceOutcomeFor, defencePenaltyFor,
|
|
conditionFor, dyingLimitFor, REACTION,
|
|
locationFor, resolveLocationHit, locationsFor, locationMaxHp,
|
|
locationEffectsFor, woundPenaltyFrom
|
|
} from "../rules.mjs";
|
|
import { WEAPONS, ARMOURS, NPCS, PREGENS } from "./content.mjs";
|
|
import { ROSTER } from "./roster.mjs";
|
|
import { expandFromRegister } from "./expand-spec.mjs";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const WEAPON = new Map(WEAPONS.map(w => [w.key, w]));
|
|
const ARMOUR = new Map(ARMOURS.map(a => [a.key, a]));
|
|
|
|
/* Readiness is the best of these, floored by INT x 3 — RINGBRP.readinessSkills and
|
|
readinessIntFactor in the engine. Named here because node cannot import them. */
|
|
const READINESS_SKILLS = ["tradecraft", "insight", "spot", "listen"];
|
|
const READINESS_INT_FACTOR = 3;
|
|
|
|
/* ---------------------------------------------------------------- randomness */
|
|
|
|
/** mulberry32 — small, fast, and seedable, which is the only property that matters. */
|
|
function makeRng(seed) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a = (a + 0x6D2B79F5) >>> 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
const d = (rng, sides) => 1 + Math.floor(rng() * sides);
|
|
const d100 = rng => d(rng, 100);
|
|
|
|
/** "1d6+2", "2d6", "-1d3", "0" -> { n, sides, mod, sign }. */
|
|
function parseDice(spec) {
|
|
const s = String(spec ?? "0").trim();
|
|
if (!s || s === "0") return { n: 0, sides: 0, mod: 0, sign: 1 };
|
|
const m = s.match(/^([+-])?(\d*)d(\d+)([+-]\d+)?$/i);
|
|
if (!m) {
|
|
const flat = Number(s);
|
|
return { n: 0, sides: 0, mod: Number.isFinite(flat) ? flat : 0, sign: 1 };
|
|
}
|
|
return {
|
|
sign: m[1] === "-" ? -1 : 1,
|
|
n: m[2] ? Number(m[2]) : 1,
|
|
sides: Number(m[3]),
|
|
mod: m[4] ? Number(m[4]) : 0
|
|
};
|
|
}
|
|
|
|
const rollDice = (rng, p) => {
|
|
let t = 0;
|
|
for (let i = 0; i < p.n; i++) t += d(rng, p.sides);
|
|
return p.sign * (t + p.mod);
|
|
};
|
|
/** Maximum, for a critical: "maximum weapon damage, plus a rolled damage modifier". */
|
|
const maxDice = p => p.sign * (p.n * p.sides + p.mod);
|
|
|
|
/* ---------------------------------------------------------------- combatants */
|
|
|
|
function skillMap(spec) {
|
|
const m = new Map();
|
|
for (const s of spec.skills ?? []) m.set(`${s.fam}:${s.spec ?? ""}`, s.val);
|
|
return m;
|
|
}
|
|
|
|
/**
|
|
* A spec written as a job carries no skills and no kit — everything a roster agent can
|
|
* do comes out of the register at build time. Expand it exactly as the build does, or
|
|
* the simulated agent is a civilian with her hands up rather than a Senior Field
|
|
* Officer. A spec that already lists its own skills is a written creature and is used
|
|
* as it stands.
|
|
*/
|
|
function ready(spec) {
|
|
if (spec.skills) return spec;
|
|
if (!spec.role && !spec.trade) return spec;
|
|
return expandFromRegister(spec, { where: "simulate" });
|
|
}
|
|
|
|
/** Everything the loop needs, all of it derived through rules.mjs. */
|
|
export function buildCombatant(rawSpec, { side = "?" } = {}) {
|
|
const spec = ready(rawSpec);
|
|
const ch = spec.ch;
|
|
const skills = skillMap(spec);
|
|
const hp = hitPointsFor(ch.con, ch.siz);
|
|
|
|
const readiness = Math.max(
|
|
(ch.int ?? 0) * READINESS_INT_FACTOR,
|
|
...READINESS_SKILLS.map(k => skills.get(`${k}:`) ?? 0)
|
|
);
|
|
|
|
// Every weapon it can actually use, best FIRST — and "best" is expected damage per
|
|
// attack, not the highest rating. Sorting on rating alone had the Cordera miners
|
|
// punching at brawl 45 instead of drawing the Webley at firearm 35, trading 1d10 for
|
|
// 1d3 because the number was bigger. Nobody chooses a weapon that way.
|
|
// buildActor gives everyone punch, so nobody is ever weaponless — same reason it does.
|
|
const carried = [...new Set(["punch", ...(spec.weapons ?? []), ...(spec.extraWeapons ?? [])])];
|
|
const arms = carried.map(key => {
|
|
const w = WEAPON.get(key);
|
|
if (!w) return null;
|
|
const id = w.spec ? `${w.fam}:${w.spec}` : `${w.fam}:`;
|
|
const rating = skills.get(id) ?? 0;
|
|
const dmg = parseDice(w.dmg);
|
|
const meanDmg = dmg.sign * (dmg.n * (dmg.sides + 1) / 2 + dmg.mod);
|
|
// resolveBands carries the 1% floor and the cap, so even an untrained weapon scores
|
|
// something and nothing has to know the banding rules here.
|
|
const hitChance = resolveBands(rating).band / 100;
|
|
return { w, rating, dmg, usesMod: w.mod !== false, expected: hitChance * meanDmg };
|
|
}).filter(Boolean).sort((a, b) => b.expected - a.expected);
|
|
|
|
const worn = (spec.armour ?? []).reduce((t, k) => t + (ARMOUR.get(k)?.points ?? 0), 0);
|
|
|
|
/* Every location this species has, each with its own share of the hit points. The
|
|
share comes from the creature's OWN table, so a cadence gets five bodies at 0.28
|
|
apiece and no vital at all, and a vesh gets a ridge instead of a head. */
|
|
const species = spec.species ?? "baseline";
|
|
const locations = locationsFor(species).map(l => ({
|
|
id: l.id, kind: l.kind,
|
|
max: locationMaxHp(hp, l.frac),
|
|
taken: 0, disabled: false, destroyed: false
|
|
}));
|
|
|
|
return {
|
|
side, key: spec.key, name: spec.name,
|
|
species,
|
|
hpMax: hp, hp,
|
|
majorWound: majorWoundFor(hp),
|
|
majorWounds: 0,
|
|
dmgMod: parseDice(damageModifierFor((ch.str ?? 0) + (ch.siz ?? 0))),
|
|
reactionBase: reactionBaseFrom(ch.dex, readiness),
|
|
armour: worn + (Number(spec.naturalArmour) || 0),
|
|
dodge: skills.get("dodge:") ?? 0,
|
|
arms,
|
|
locations,
|
|
wounded: locationEffectsFor(locations),
|
|
defencesThisRound: 0,
|
|
dyingRounds: 0,
|
|
startedUp: true
|
|
};
|
|
}
|
|
|
|
/* A destroyed head is "unconscious immediately", which conditionFor has always known how
|
|
to read and this harness never told it — so a headshot used to leave the target
|
|
standing and swinging. */
|
|
const conditionOf = c => conditionFor({
|
|
hp: c.hp, dyingRounds: c.dyingRounds, majorWound: c.majorWounds,
|
|
unconscious: !!c.wounded?.unconscious
|
|
});
|
|
const isFighting = c => conditionOf(c) === "up";
|
|
|
|
/* ---------------------------------------------------------------- one fight */
|
|
|
|
function attack(rng, attacker, defender) {
|
|
const arm = attacker.arms[0];
|
|
if (!arm) return;
|
|
|
|
// Where a blow lands depends on how it was thrown: melee finds limbs, because they are
|
|
// between you and the target, and shooting finds centre of mass.
|
|
const mode = arm.w.cls === "melee" ? "melee" : "ranged";
|
|
|
|
// A wounded attacker is a worse attacker. Melee and ranged both take the MANIPULATION
|
|
// penalty — losing an arm is what stops you fighting, whatever is in your hand — and
|
|
// the mapping comes from rules.mjs rather than being restated here.
|
|
const atkPen = woundPenaltyFrom(attacker.wounded, mode);
|
|
const level = gradeRoll(d100(rng), resolveBands(arm.rating, { situational: atkPen }));
|
|
if (level === "fumble" || level === "failure") return;
|
|
|
|
// One defence per incoming blow, at the stacking penalty for each already spent this
|
|
// round, PLUS whatever a ruined pair of legs costs — dodge is a `phys` skill, so a
|
|
// disabled leg is exactly what stops you getting out of the way.
|
|
let landing = level;
|
|
if (defender.dodge > 0) {
|
|
const penalty = defencePenaltyFor(defender.defencesThisRound)
|
|
+ woundPenaltyFrom(defender.wounded, "phys");
|
|
const dLevel = gradeRoll(d100(rng), resolveBands(defender.dodge, { situational: penalty }));
|
|
defender.defencesThisRound++;
|
|
const outcome = defenceOutcomeFor(level, dLevel);
|
|
if (outcome.turnedAside) return;
|
|
landing = outcome.landsAt;
|
|
}
|
|
|
|
// Critical: maximum weapon damage, plus a rolled damage modifier, armour ignored
|
|
// entirely. Special: normal damage, armour counts half rounded down. Success: normal
|
|
// damage, full armour. Straight out of the rules journal's "What a hit does".
|
|
let dmg = landing === "critical" ? maxDice(arm.dmg) : rollDice(rng, arm.dmg);
|
|
if (arm.usesMod) dmg += rollDice(rng, attacker.dmgMod);
|
|
dmg = Math.max(1, dmg); // R-56: a hit that connects always does something
|
|
|
|
const armour = landing === "critical" ? 0
|
|
: landing === "special" ? Math.floor(defender.armour / 2)
|
|
: defender.armour;
|
|
|
|
const taken = Math.max(0, dmg - armour);
|
|
if (taken <= 0) return;
|
|
|
|
/* Locate it. 1d20 against the defender's own species table, then the damage goes to
|
|
that location AND to general hit points — the two systems agree rather than
|
|
competing, which is what resolveLocationHit is for.
|
|
|
|
This is where a Cadence stops being a person with a funny picture. Its five bodies
|
|
each carry 0.28 of the pool, it has no vital and no head, so it cannot be dropped by
|
|
one good shot; instead every body that goes costs the whole creature -10% to
|
|
everything, and it degrades toward useless rather than dying. A Vesh, conversely,
|
|
has a ridge for a vital that is a LONG target rather than a small one. None of that
|
|
could be measured before, because damage was never located. */
|
|
const loc = locationFor(d(rng, 20), defender.species, mode);
|
|
const slot = defender.locations.find(l => l.id === loc.id) ?? defender.locations[0];
|
|
const res = resolveLocationHit({
|
|
damage: taken,
|
|
locationMax: slot.max,
|
|
locationTaken: slot.taken,
|
|
majorWoundThreshold: defender.majorWound
|
|
});
|
|
slot.taken = res.locationTaken;
|
|
slot.disabled = res.disabled;
|
|
slot.destroyed = res.destroyed;
|
|
if (res.majorWound) defender.majorWounds++;
|
|
|
|
defender.hp -= taken;
|
|
// Recomputed from the whole body, not accumulated, because the effects fold together
|
|
// rather than stacking per hit — two disabled legs are one condition, not two.
|
|
defender.wounded = locationEffectsFor(defender.locations);
|
|
}
|
|
|
|
/** One fight to a conclusion. Returns which side stood, and how long it took. */
|
|
export function runFight(rng, partySpecs, enemySpecs, { maxRounds = 40 } = {}) {
|
|
const party = partySpecs.map(s => buildCombatant(s, { side: "party" }));
|
|
const enemy = enemySpecs.map(s => buildCombatant(s, { side: "enemy" }));
|
|
const all = [...party, ...enemy];
|
|
|
|
let round = 0;
|
|
for (; round < maxRounds; round++) {
|
|
if (!party.some(isFighting) || !enemy.some(isFighting)) break;
|
|
|
|
// Reaction is re-rolled EVERY round, including the first — the opening round is
|
|
// ordered like every other one. Load is not modelled, so this is base plus a die.
|
|
for (const c of all) {
|
|
c.defencesThisRound = 0;
|
|
c.initiative = c.reactionBase + d(rng, REACTION.die);
|
|
}
|
|
const order = [...all].sort((a, b) => b.initiative - a.initiative);
|
|
|
|
for (const actor of order) {
|
|
if (!isFighting(actor)) continue;
|
|
const foes = (actor.side === "party" ? enemy : party).filter(isFighting);
|
|
if (!foes.length) break;
|
|
attack(rng, actor, foes[Math.floor(rng() * foes.length)]);
|
|
}
|
|
|
|
// End of round: anyone at or below zero loses ground on the dying clock.
|
|
for (const c of all) {
|
|
if (c.hp <= 0 && conditionOf(c) !== "dead") c.dyingRounds++;
|
|
}
|
|
}
|
|
|
|
const standing = party.filter(isFighting).length;
|
|
return {
|
|
rounds: round,
|
|
partyStanding: standing,
|
|
partyDown: party.length - standing,
|
|
partyHurt: party.filter(c => c.hp < c.hpMax).length,
|
|
wiped: standing === 0,
|
|
won: !enemy.some(isFighting) && standing > 0,
|
|
dead: party.filter(c => conditionOf(c) === "dead").length
|
|
};
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- many fights */
|
|
|
|
export function measure(partySpecs, enemySpecs, { runs = 400, seed = 1 } = {}) {
|
|
const rng = makeRng(seed);
|
|
const rounds = [], hurt = [], down = [];
|
|
let wipes = 0, wins = 0, dead = 0;
|
|
|
|
for (let i = 0; i < runs; i++) {
|
|
const r = runFight(rng, partySpecs, enemySpecs);
|
|
rounds.push(r.rounds); hurt.push(r.partyHurt); down.push(r.partyDown);
|
|
if (r.wiped) wipes++;
|
|
if (r.won) wins++;
|
|
dead += r.dead;
|
|
}
|
|
const mean = xs => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
const median = xs => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
|
|
|
|
return {
|
|
runs, seed,
|
|
partySize: partySpecs.length,
|
|
enemyCount: enemySpecs.length,
|
|
hurtMean: mean(hurt),
|
|
downMean: mean(down),
|
|
roundsMedian: median(rounds),
|
|
wipeRate: wipes / runs,
|
|
winRate: wins / runs,
|
|
deathsPerRun: dead / runs
|
|
};
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- the corpus */
|
|
|
|
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"]) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- cli */
|
|
|
|
/* Only when run directly. Everything above is importable — check-lethality will want
|
|
measure(), and a module that exits the process on import is no use to it. */
|
|
const invokedDirectly = process.argv[1]
|
|
&& import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
|
|
if (invokedDirectly) {
|
|
|
|
const argv = process.argv.slice(2);
|
|
const opt = (n, dflt) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : dflt; };
|
|
const has = n => argv.includes(n);
|
|
|
|
if (has("--list")) {
|
|
const rows = [...ALL_SPECS.values()].map(s => ` ${s.key.padEnd(24)} ${s.name}`);
|
|
console.log(`${rows.length} specs:\n${rows.sort().join("\n")}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
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 --creature <key> [--count N] --spread [--party-size N]\n"
|
|
+ " node tools/simulate.mjs --list");
|
|
process.exit(1);
|
|
}
|
|
|
|
const enemySpecs = [];
|
|
for (const k of creatureKeys) {
|
|
const spec = ALL_SPECS.get(k);
|
|
if (!spec) { console.error(`no such creature "${k}" — try --list`); process.exit(1); }
|
|
enemySpecs.push(spec);
|
|
}
|
|
const count = Number(opt("--count", 0));
|
|
if (count > 0) {
|
|
const one = enemySpecs[0];
|
|
enemySpecs.length = 0;
|
|
for (let i = 0; i < count; i++) enemySpecs.push(one);
|
|
}
|
|
|
|
/* The party is duty-roster agents: a number takes the first N, or name them. */
|
|
const partyArg = opt("--party", "4");
|
|
let partySpecs;
|
|
if (/^\d+$/.test(partyArg)) {
|
|
partySpecs = ROSTER.slice(0, Number(partyArg));
|
|
} else {
|
|
partySpecs = partyArg.split(",").map(n => {
|
|
const want = n.trim().toLowerCase();
|
|
const found = ROSTER.find(r => r.key === want || r.key === `pc_${want}`
|
|
|| r.name.toLowerCase().includes(want));
|
|
if (!found) { console.error(`no roster agent "${n}" — try --list`); process.exit(1); }
|
|
return found;
|
|
});
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ 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))
|
|
});
|
|
|
|
const pct = x => `${(x * 100).toFixed(1)}%`;
|
|
console.log(`
|
|
${enemySpecs.length} x ${enemySpecs[0].name} vs ${partySpecs.length} duty-roster agents
|
|
${partySpecs.map(p => p.name).join(", ")}
|
|
${result.runs} runs, seed ${result.seed}
|
|
|
|
agents hurt ${result.hurtMean.toFixed(2)} of ${result.partySize}
|
|
agents down ${result.downMean.toFixed(2)} of ${result.partySize}
|
|
median rounds ${result.roundsMedian}
|
|
party wiped ${pct(result.wipeRate)}
|
|
party won outright ${pct(result.winRate)}
|
|
deaths per run ${result.deathsPerRun.toFixed(2)}
|
|
`);
|
|
|
|
}
|