/** * 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 [,] [--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)} `); }