simulate: a lethality harness that uses the real rules
THROUGH TRAIN publishes "MEASURED, 400 runs each" and a 24% wipe rate; the starter publishes its own table. Nothing in this repository could produce either number, so both were claims rather than results and neither could be re-checked after a rule moved. The harness knows no rules of its own. Everything comes out of rules.mjs, because a harness with its own copy of the combat loop measures a game nobody is playing and drifts silently the first time a rule changes. Two things had to move for that to be possible: applyDifficulty, resolveBands and gradeRoll -> rules.mjs. The whole of d100 resolution lived in ringbrp.mjs, which node cannot import. All three are pure, so they moved and the engine re-exports them; no caller changes. check-rules then refused them for having no spot-check, so they now have fifteen, covering the 1% floor, the 96-99 rule and 00-always-fumbles. They had none in their entire life inside the engine. expandFromRegister -> tools/expand-spec.mjs. A roster agent is written as a job and a rank and carries no skills or kit; everything it can do is derived at build time by a function locked inside build-packs.mjs, which cannot be imported because importing it runs the build. A simulated agent expanded by a second copy of that logic is not the agent that gets packed. Moved verbatim; both sides import one copy. Deterministic throughout: same creature, party and seed, same numbers, which is what lets a published table be verified rather than remembered. What it finds: for the Act Three standoff the seed barely matters (0.5-2.8% wipe across five seeds, eight rounds throughout) and party composition dominates everything. Four support agents against the six miners wipe 92.8% of the time; swap in the heavy and the warden and it is 0.3%. The published 24% is not a property of the encounter, and no single figure can be. Models Reaction re-rolled each round, banding, graded defences, the stacking defence penalty, damage by band, major wounds and the dying clock. Does NOT yet model hit locations, bleeding, panic, cover, range bands or fire modes, all of which make a losing fight worse. Read the output as a floor, never a ceiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c3002e93c9
commit
2c547efe72
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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 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 does NOT model
|
||||
* hit locations, bleeding, panic, Coherence, cover, range bands, fire modes or burst.
|
||||
* Those all exist in rules.mjs and all matter; they are not here yet, and every one of
|
||||
* them makes a fight WORSE for whoever is losing. Read the output as a floor on how bad
|
||||
* a creature is, never as a ceiling.
|
||||
*/
|
||||
import {
|
||||
hitPointsFor, majorWoundFor, damageModifierFor, reactionBaseFrom,
|
||||
resolveBands, gradeRoll, defenceOutcomeFor, defencePenaltyFor,
|
||||
conditionFor, dyingLimitFor, REACTION
|
||||
} 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);
|
||||
|
||||
return {
|
||||
side, key: spec.key, name: spec.name,
|
||||
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,
|
||||
defencesThisRound: 0,
|
||||
dyingRounds: 0,
|
||||
startedUp: true
|
||||
};
|
||||
}
|
||||
|
||||
const conditionOf = c => conditionFor({
|
||||
hp: c.hp, dyingRounds: c.dyingRounds, majorWound: c.majorWounds
|
||||
});
|
||||
const isFighting = c => conditionOf(c) === "up";
|
||||
|
||||
/* ---------------------------------------------------------------- one fight */
|
||||
|
||||
function attack(rng, attacker, defender) {
|
||||
const arm = attacker.arms[0];
|
||||
if (!arm) return;
|
||||
|
||||
const level = gradeRoll(d100(rng), resolveBands(arm.rating));
|
||||
if (level === "fumble" || level === "failure") return;
|
||||
|
||||
// One defence per incoming blow, at the stacking penalty for each already spent this
|
||||
// round. A defender with nothing left still gets the roll — at a rating the penalty
|
||||
// has usually driven to the 1% floor, which resolveBands enforces rather than us.
|
||||
let landing = level;
|
||||
if (defender.dodge > 0) {
|
||||
const penalty = defencePenaltyFor(defender.defencesThisRound);
|
||||
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;
|
||||
if (taken >= defender.majorWound) defender.majorWounds++;
|
||||
defender.hp -= taken;
|
||||
}
|
||||
|
||||
/** 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 --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;
|
||||
});
|
||||
}
|
||||
|
||||
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)}
|
||||
`);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user