Files
RingBRP/tools/simulate.mjs
T
slaguru666andClaude Opus 5 aa3ae24ef2 CLEAN GROUND v0.16 — the fight it was never measuring, and R-283
Applies desk pass 6's seven fixes.

1. EXPOSURE now measures the fight the table will have. Every row in the old
   table was one force fighting alone, and the six at the back are never
   alone — they walk inside forty-one refugees. Six hollow men wipe 0.0%;
   with two of the column joining, 7.8%; with three, 28.7%. Two is now the
   stated default, because two people out of forty-one losing their heads
   while their neighbours are shot is not a large number.
2. The four-player block has hollow-man rows: 0.3% at three, 58.7% at six,
   99.9% with two of the column. Its only hollow-man figure before was the
   six-player 0.0%, so a GM running the cut was reading somebody else's
   table — for the encounter the party is likeliest to choose, because the
   six at the back are the only figures the scenario says are not people.
   GM ESSENTIALS item 3 carries the same correction.
3. Act Three's warning named a roll that does not exist. Its twenty-minute
   bomb hangs off "Anomaly Lore — what a peg is"; the depot entry is a
   Research roll. Pass 4's post-pass inherited the conflation from this
   warning and is corrected too.
4. GM ESSENTIALS states the real fumble band. fumbleStart is
   101 - ceil((101-band)/20), tested before the 96-99 clause: 00 at 85,
   99-00 at 63, 98-00 at 53, 97-00 at 40. Four times what "00 always
   fumbles" implies, in a case that rolls Spot 40 across two acts. Pass 4's
   correction was right at 63 by luck and would have been wrong at 53.
5. Sixth EXPOSURE lesson: a fight costs the session. Median 13 rounds on the
   printed row, 24 mixed, 30 at four players — sixty to ninety minutes in an
   act budgeted at fifty. The Pacing Note now says what to do when one
   starts, and not to absorb a fight and the peg fumble in the same act.
6. The fumbled Xenology is written. At 53 it fumbles on 98-00 and Braithwaite
   puts his name to a baseline human in front of everybody. The un-gated tell
   still arrives; it now costs the party its expert.
7. R-283: simulate.mjs described a mixed force as N of whichever spec came
   first, so six hollow men and six of the column printed as "12 x Hollow
   man" — two fights 88 points of wipe rate apart under one label. The
   composition was always right and the report was not. Uniform and --spread
   output are byte-identical, so nothing already published goes stale.

Every figure re-run before writing rather than carried over. npm run check:
18 guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:16:17 +01:00

676 lines
32 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, armourAgainst, damageAfterArmour,
exposedHideFor, naturalArmourOn, locationArmourFor, groundedPlanFor, remapLocationDamage
} from "../rules.mjs";
import { combatPowerFor } from "./powers.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 */
/**
* A seed of this creature's own.
*
* Every creature used to be measured against seed 11, which made each one independent
* of its neighbours — inserting a creature perturbs nobody — but gave all forty-seven
* of them the SAME two hundred dice. One draw's luck was therefore repeated across the
* whole bestiary: if seed 11 happens to roll cold for the party, every creature in the
* book looks a little deadlier than it is, and the error points the same way in all of
* them, so no amount of reading across the table reveals it.
*
* Mixing the creature's key into the base seed decorrelates them without giving up
* anything the guard depends on. It is still deterministic, still reproducible from the
* base seed alone, and still local: a creature's number is a function of its own key,
* so adding, removing or reordering the bestiary leaves every other row untouched.
* Renaming a key does change that creature's dice, which is correct — a renamed key is
* a different row in the baseline anyway.
*
* FNV-1a, because it is four lines and the only property that matters is that different
* keys land far apart.
*/
export function seedFor(base, key = "") {
let h = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return ((h ^ (Number(base) >>> 0)) >>> 0);
}
/** mulberry32 — small, fast, and seedable, which is the only property that matters. */
/* R-265. Exported because anything that cites a measured fight has to draw from the same
stream to name the same fight. tools/playthrough.mjs had its own LCG, so "seed 2" meant
one fight there and a different one here, which quietly broke the only thing that tool
exists for. */
export 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);
/* R-264. Worn armour is PIECES, not a total: a stab vest covers a torso, and summing
its points into one number put it on the wearer's head. Coverage comes from the
catalogue exactly as build-packs reads it. */
const worn = (spec.armour ?? []).map(k => ARMOUR.get(k)).filter(Boolean)
.map(a => ({ points: Number(a.points) || 0, coverage: a.covers ?? "all" }));
/* R-264. The BODY PLAN, not the species — `spec.bodyPlan ?? spec.species ?? "baseline"`
is what build-packs:294 writes to the actor's speciesProfile, and this harness read
`species` alone. Four creatures were fought human-shaped: the barghest, the kelpie
and the church grim on two legs with arms, and the supporter with no wings at all,
so the one fight its own tactics call winnable could not happen in a measured fight.
Every location carries its own share of the hit points, plus the two fields the hide
rules need: `bare` (a wing carries no hide) and any armour the table gives it. */
const species = spec.species ?? "baseline";
const plan = spec.bodyPlan ?? species;
const mkLocations = (planId, tookBefore = {}) => locationsFor(planId).map(l => {
const max = locationMaxHp(hp, l.frac);
const taken = Number(tookBefore[l.id]) || 0;
return { id: l.id, kind: l.kind, bare: !!l.bare, armour: Number(l.armour) || 0,
max, taken, disabled: taken >= max, destroyed: taken >= max * 2 };
});
const locations = mkLocations(plan);
return {
side, key: spec.key, name: spec.name,
species,
/* R-275. What its own statblock says it does in a fight, classified in powers.mjs.
Null for anybody the harness has no fight rule for, which is most of them. */
power: combatPowerFor(spec.key),
hpMax: hp, hp,
majorWound: majorWoundFor(hp),
majorWounds: 0,
dmgMod: parseDice(damageModifierFor((ch.str ?? 0) + (ch.siz ?? 0))),
reactionBase: reactionBaseFrom(ch.dex, readiness),
/* Kept as the creature's overall hide for reporting; what a blow actually meets is
computed per location by armourAt(), because a wing is bare and a grounded belly
is not the back it was drawn with. */
naturalArmour: Number(spec.naturalArmour) || 0,
armour: (Number(spec.naturalArmour) || 0) + worn.reduce((t, p) => t + p.points, 0),
worn,
plan, grounded: false, mkLocations,
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 */
/* R-264. What a blow actually meets at one location: the creature's own hide, less
whatever the posture and the location take off it, plus every worn piece that reaches
that kind. Every term comes from rules.mjs — check-rules forbids restating any of it
here, which is how R-257 ended. */
function armourAt(c, slot) {
return locationArmourFor(
slot.armour + naturalArmourOn(slot, exposedHideFor(c.naturalArmour, c.grounded, slot.kind)),
c.worn, slot.kind);
}
/* R-264. A flyer with a hole in a wing is a quadruped — the rule the supporter's tactics
call "the fight the agents can actually win", which no measured fight could reach
because this harness never changed a creature's body mid-fight. Wounds carry across by
SEVERITY through remapLocationDamage, the same function the actor's _preUpdate uses, so
a ruined wing arrives as a ruined foreleg rather than being quietly forgiven. */
function groundIfWinged(c, slot, say) {
if (c.grounded || slot.kind !== "wing" || !(slot.disabled || slot.destroyed)) return;
const landed = groundedPlanFor(c.plan);
if (!landed || landed === c.plan) return;
const took = Object.fromEntries(c.locations.filter(l => l.taken > 0).map(l => [l.id, l.taken]));
/* remapLocationDamage returns { damage, moved, rescaled }, and the first draft of this
handed the whole object to mkLocations — so every wound the creature was carrying was
silently forgiven the moment it came down. No guard would have caught it: fewer wounds
just means a longer fight, which reads as a number moving. The actor's _preUpdate
destructures it the same way at ringbrp.mjs:462. */
const { damage: carried } = remapLocationDamage(took, c.plan, landed, c.hpMax);
c.locations = c.mkLocations(landed, carried);
c.plan = landed;
c.grounded = true;
c.wounded = locationEffectsFor(c.locations);
say({ kind: "grounded", defender: c, plan: landed,
hide: exposedHideFor(c.naturalArmour, true, "leg"),
vital: exposedHideFor(c.naturalArmour, true, "vital") });
}
/* R-263. `say` is an optional narration sink, passed down from runFight and defaulting to
a no-op. It reports what the simulator already decided — it never touches rng, so a
narrated fight and a silent one are the same fight, which is the only way a play-by-play
of this harness is worth reading. check-lethality and check-focus both prove it. */
function attack(rng, attacker, defender, say = () => {}) {
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 roll = d100(rng);
/* THE CONSIGNMENT: the courier will not fight with the limb that is carrying, so it
attacks at half rating until the consignment is taken from it. */
const atkFactor = Number(attacker.power?.attackFactor);
const rating = atkFactor > 0 && atkFactor < 1
? Math.floor(arm.rating * atkFactor) : arm.rating;
const bands = resolveBands(rating, { situational: atkPen });
const level = gradeRoll(roll, bands);
if (level === "fumble" || level === "failure") {
say({ kind: "miss", attacker, defender, arm, roll, target: bands.effective, level });
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) {
/* NOT TIRED: a redcap's entry says it ignores the cumulative penalty entirely, and
until R-275 the harness charged it anyway — so every figure this project has
published about redcaps described a creature that tires. Wounds still count: the
power exempts it from spending defences, not from having a ruined leg. */
const stacking = defender.power?.defenceStacking === "ignores"
? 0 : defencePenaltyFor(defender.defencesThisRound);
const penalty = stacking + woundPenaltyFrom(defender.wounded, "phys");
const dBands = resolveBands(defender.dodge, { situational: penalty });
const dRoll = d100(rng);
const dLevel = gradeRoll(dRoll, dBands);
defender.defencesThisRound++;
const outcome = defenceOutcomeFor(level, dLevel);
if (outcome.turnedAside) {
say({ kind: "dodged", attacker, defender, arm, roll, level,
dodgeRoll: dRoll, dodgeTarget: dBands.effective, dLevel, penalty });
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
/* R-264. WHERE IT LANDS IS DECIDED BEFORE WHAT STOPS IT, because that is the order the
game resolves in and the two cannot be separated: a wing carries no hide, a grounded
belly carries half, and a chest carries all of it whatever posture it is in. This
harness used to apply one armour number for the whole creature and then locate the
blow afterwards, so the entire design of R-254 and R-255 was invisible to every
number it produced. It also returned early when armour stopped a blow, before the
location was rolled at all. */
const loc = locationFor(d(rng, 20), defender.plan, mode);
const slot = defender.locations.find(l => l.id === loc.id) ?? defender.locations[0];
const metArmour = armourAt(defender, slot); // before this blow changes anything
const armour = armourAgainst(landing, metArmour);
/* ARGENT AND GULES halves what the supporter takes from anything the borough owns,
which is every weapon signed out of Stores — so against the department it always
applies. Floored, like every other halving in this game. */
const factor = Number(defender.power?.damageFactor);
const afterArmour = damageAfterArmour(dmg, armour);
const taken = factor > 0 && factor < 1 ? Math.floor(afterArmour * factor) : afterArmour;
if (taken <= 0) {
say({ kind: "stopped", attacker, defender, arm, roll, level, landing,
rolled: dmg, armour: metArmour, effectiveArmour: armour,
location: slot, locationLabel: loc.label });
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 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);
say({ kind: "hit", attacker, defender, arm, roll, level, landing,
rolled: dmg, armour: metArmour, effectiveArmour: armour, taken,
location: slot, locationLabel: loc.label, majorWound: res.majorWound,
disabled: res.disabled, destroyed: res.destroyed });
// After the blow is reported, not before it: the wing is ruined by a hit the reader
// has to have seen first.
groundIfWinged(defender, slot, say);
}
/** One fight to a conclusion. Returns which side stood, and how long it took. */
export function runFight(rng, partySpecs, enemySpecs,
{ maxRounds = 40, partySequence = null, partyTargets = "random", say = null } = {}) {
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;
say?.({ kind: "round", round: round + 1, party, enemy });
// 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);
}
let order = [...all].sort((a, b) => b.initiative - a.initiative);
/* R-259. An experiment hook, off by default and never used by the baseline: it
rearranges the PARTY among the slots initiative already gave them, leaving every
enemy where it fell. That isolates "what order the agents act in" from "who acts
before the creature does" — reordering the whole line would measure both at once
and credit the answer to the wrong one. The initiative dice are still rolled
either way, so the random stream is identical with the hook on or off. */
if (partySequence) {
const slots = order.map((c, i) => [c, i]).filter(([c]) => c.side === "party").map(([, i]) => i);
const seq = partySequence(order.filter(c => c.side === "party"));
order = [...order];
slots.forEach((slot, k) => { order[slot] = seq[k]; });
}
for (const actor of order) {
if (!isFighting(actor)) continue;
const foes = (actor.side === "party" ? enemy : party).filter(isFighting);
if (!foes.length) break;
/* R-259, second hook, also off by default: "focus" makes the party concentrate on
one enemy — the most hurt of them — instead of each agent picking at random.
The die is rolled either way so the stream does not shift. */
const pick = Math.floor(rng() * foes.length);
const focus = actor.side === "party" && partyTargets === "focus"
? foes.reduce((a, b) => (b.hp < a.hp ? b : a))
: foes[pick];
say?.({ kind: "turn", actor, target: focus });
attack(rng, actor, focus, say ?? undefined);
}
// 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);
}
/* R-283. The header used to read `${enemySpecs.length} x ${enemySpecs[0].name}`, which is
only true when every enemy is the same spec. A mixed force — `--creature a,a,b` — was
built correctly and then DESCRIBED as N of whatever came first, so six hollow men and
six of the column printed as "12 x Hollow man". Two fights that differ by 88 points of
wipe rate shared a label. The composition was never wrong; the report was, which is the
defect this project keeps finding in itself, and desk pass 6 took every figure in its
two headline findings out of a run whose header lied about what was fought. */
const forceLabel = specs => {
const tally = new Map();
for (const sp of specs) tally.set(sp.name, (tally.get(sp.name) ?? 0) + 1);
return [...tally].map(([name, n]) => `${n} x ${name}`).join(" + ");
};
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 — ${forceLabel(enemySpecs)}
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(`
${forceLabel(enemySpecs)} 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)}
`);
}