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:
slaguru666
2026-08-30 21:10:05 +01:00
co-authored by Claude Opus 5
parent c3002e93c9
commit 2c547efe72
7 changed files with 543 additions and 111 deletions
+1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs", "build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
"icons": "node tools/make-icons.mjs", "icons": "node tools/make-icons.mjs",
"simulate": "node tools/simulate.mjs",
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs && node tools/check-scenarios.mjs && node tools/check-creatures.mjs", "check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs && node tools/check-scenarios.mjs && node tools/check-creatures.mjs",
"test": "npm run check", "test": "npm run check",
"readme": "node tools/update-readme.mjs" "readme": "node tools/update-readme.mjs"
+5 -39
View File
@@ -27,6 +27,7 @@ import {
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom,
applyDifficulty, resolveBands, gradeRoll,
DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY, destructionOutcomeFor, DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY, destructionOutcomeFor,
RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor, RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor,
FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, fireBandFor, FALL, fallDamageFor, ASPHYXIA, breathRoundsFor, FIRE, fireBandFor,
@@ -449,45 +450,10 @@ export function fluxDie(face, coherence) {
/** Spec §1. Difficulty multiplies the rating FIRST, then situational modifiers add. */ /** Spec §1. Difficulty multiplies the rating FIRST, then situational modifiers add. */
export function applyDifficulty(rating, difficulty = "average") { /* applyDifficulty, resolveBands and gradeRoll now live in rules.mjs — they are pure
switch (difficulty) { rules and the lethality harness has to reach them from node, which cannot import this
case "easy": return rating * 2; file. Re-exported here so every existing caller and import path is unchanged. */
case "difficult": return Math.floor(rating / 2); export { applyDifficulty, resolveBands, gradeRoll };
case "average":
default: return rating;
}
}
/**
* Spec §1 resolution. Returns the full band breakdown without rolling,
* so sheets can display the odds before the player commits.
*/
export function resolveBands(rating, { difficulty = "average", situational = 0, printedBase = 0 } = {}) {
let effective = applyDifficulty(Number(rating) || 0, difficulty) + (Number(situational) || 0);
if (printedBase >= 5) effective = Math.max(effective, 5);
const band = Math.min(100, Math.max(1, effective));
// `effective` used to be the RAW figure, so a skill beaten down to 0 or below
// printed "0%" on the chat card while the true chance was 1% — the floor was
// real but arrived sideways, through the critical band, and the card contradicted
// the rule the book states. One honest number now: what you actually need.
return {
effective: band,
band,
critical: ceil(band / 20),
special: ceil(band / 5),
fumbleStart: 101 - ceil((101 - band) / 20)
};
}
/** Spec §1. Fumble is tested first; 96–99 never succeed; 00 always fumbles. */
export function gradeRoll(roll, bands) {
if (roll >= bands.fumbleStart) return "fumble";
if (roll >= 96 && roll <= 99) return "failure";
if (roll <= bands.critical) return "critical";
if (roll <= bands.special) return "special";
if (roll <= bands.effective) return "success";
return "failure";
}
/** Spec §4.4. Returns {band, mod} or {band:"beyond", mod:null} when impossible. */ /** Spec §4.4. Returns {band, mod} or {band:"beyond", mod:null} when impossible. */
+52
View File
@@ -1513,6 +1513,58 @@ export function skillBaseFrom(formula, characteristics = {}) {
return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0)); return Math.max(0, stat * (m[2] ? Number(m[2]) : 1) + (m[3] ? Number(m[3]) : 0));
} }
/**
* Spec §1. Difficulty is a multiplier on the rating, not a modifier on the roll.
*
* These three — applyDifficulty, resolveBands, gradeRoll — are the whole of d100
* resolution, and they lived in ringbrp.mjs until the lethality harness needed them.
* They are pure: nothing here touches an Actor, a roll message or any other Foundry
* global, so there was never a reason for them to sit in the engine except that the
* engine is where they were first written. check-rules already guards against a rule
* keeping a second copy of itself in ringbrp.mjs; a rule that lives ONLY there is the
* same problem one step earlier, because nothing outside Foundry can reach it to check
* it. ringbrp.mjs imports and re-exports them, so its public surface is unchanged.
*/
export function applyDifficulty(rating, difficulty = "average") {
switch (difficulty) {
case "easy": return rating * 2;
case "difficult": return Math.floor(rating / 2);
case "average":
default: return rating;
}
}
/**
* Spec §1 resolution. Returns the full band breakdown without rolling,
* so sheets can display the odds before the player commits.
*/
export function resolveBands(rating, { difficulty = "average", situational = 0, printedBase = 0 } = {}) {
let effective = applyDifficulty(Number(rating) || 0, difficulty) + (Number(situational) || 0);
if (printedBase >= 5) effective = Math.max(effective, 5);
const band = Math.min(100, Math.max(1, effective));
// `effective` used to be the RAW figure, so a skill beaten down to 0 or below
// printed "0%" on the chat card while the true chance was 1% — the floor was
// real but arrived sideways, through the critical band, and the card contradicted
// the rule the book states. One honest number now: what you actually need.
return {
effective: band,
band,
critical: ceil(band / 20),
special: ceil(band / 5),
fumbleStart: 101 - ceil((101 - band) / 20)
};
}
/** Spec §1. Fumble is tested first; 96–99 never succeed; 00 always fumbles. */
export function gradeRoll(roll, bands) {
if (roll >= bands.fumbleStart) return "fumble";
if (roll >= 96 && roll <= 99) return "failure";
if (roll <= bands.critical) return "critical";
if (roll <= bands.special) return "special";
if (roll <= bands.effective) return "success";
return "failure";
}
/** /**
* The five outcome bands, worst to best. Exported so nothing has to re-declare * The five outcome bands, worst to best. Exported so nothing has to re-declare
* the order to compare two rolls against each other. * the order to compare two rolls against each other.
+4 -72
View File
@@ -188,78 +188,10 @@ function deviceItem(d) {
* cant is the department's own argot and a civilian has never heard it spoken. * cant is the department's own argot and a civilian has never heard it spoken.
*/ */
const REG = await import("../postings.mjs"); const REG = await import("../postings.mjs");
const midBand = b => Math.round((b[0] + b[1]) / 2); /* expandFromRegister moved to ./expand-spec.mjs so the lethality harness can use the
SAME expansion the build uses. A simulated roster agent expanded by a second copy
function expandFromRegister(spec, { where = "roster", civilian = false } = {}) { of this logic is not the agent that gets packed. */
const { ROLES, TRADES, INDUCTION, TRADE_BANDS, STANDING_ISSUE, TIER_ISSUE, TIERS } = REG; import { expandFromRegister } from "./expand-spec.mjs";
const isTrade = !!spec.trade;
const R = isTrade ? TRADES[spec.trade] : ROLES[spec.role];
if (!R) throw new Error(`${where}: no ${isTrade ? "trade" : "role"} "${spec.trade ?? spec.role}"`);
const tierKey = { "Probationary": "probationary", "Field Officer": "officer",
"Senior Field Officer": "senior", "Case Officer": "veteran" }[spec.rank]
?? "officer";
const T = TIERS[tierKey];
const coreV = isTrade ? midBand(TRADE_BANDS.core) : midBand(T.core);
const supportV = isTrade ? midBand(TRADE_BANDS.support) : midBand(T.support);
const inducV = midBand(INDUCTION.bandByTier[tierKey] ?? INDUCTION.band);
const skills = [];
const seen = new Set();
const addSkill = (id, val) => {
if (seen.has(id)) return; // induction never demotes a trade
seen.add(id);
const [fam, sp = ""] = id.split(":");
skills.push({ fam, spec: sp, val });
};
for (const k of R.core) addSkill(k, coreV);
for (const k of R.support) addSkill(k, supportV);
// Induction is a FLOOR for everyone the department employs, posting or trade — see the
// note in ringbrp.mjs. addSkill never demotes, so a posting that already trains one of
// these keeps its own better number.
if (!civilian) for (const k of INDUCTION.skills) addSkill(k, inducV);
if (!civilian) addSkill("language:trade_cant", 55);
// Floors only ever RAISE a rating, so a posting already better stays better.
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
const [fam, sp = ""] = id.split(":");
const found = skills.find(x => x.fam === fam && x.spec === sp);
if (found) found.val = Math.max(found.val, floor);
else { skills.push({ fam, spec: sp, val: floor }); seen.add(id); }
}
// A civilian has never been near a crossing, so nothing anomalous and nothing that
// costs Coherence to use — the criminal's trade kit contains a black mirror, which is
// exactly the sort of thing you acquire AFTER the night the department noticed you.
const isAnomalous = k => {
const it = GEAR_LOOKUP.get(k) ?? WEAPON_LOOKUP.get(k) ?? ARMOUR_LOOKUP.get(k);
return !!it && (it.era === "anomalous" || !!it.coherence);
};
// No firearm and no body armour either. The police trade kit contains a sidearm and a
// stab vest, which is right for a serving officer and wrong for the person before the
// department found them — this is Britain, and the two ex-police pregens were walking
// into a civilian prologue armed and armoured. The department issues those. Playing
// LAST ADMISSION found it: its only fight was over in one round because a punter had
// a revolver. A criminal's knife stays; a knife is not an issued weapon.
const isIssuedForce = k => {
const w = WEAPON_LOOKUP.get(k);
if (w) return w.cls !== "melee";
return ARMOUR_LOOKUP.has(k);
};
const kitKeys = civilian
? [...R.kit, ...(spec.extraWeapons ?? []), ...(spec.extraGear ?? [])]
.filter(k => !isAnomalous(k) && !isIssuedForce(k))
: [...STANDING_ISSUE, ...R.kit,
...(isTrade ? INDUCTION.kit : []),
...(spec.extraWeapons ?? []), ...(spec.extraGear ?? []),
...(TIER_ISSUE[tierKey] ?? [])];
const weapons = [], armour = [], gear = [];
for (const k of new Set(kitKeys)) {
if (WEAPON_LOOKUP.has(k)) weapons.push(k);
else if (ARMOUR_LOOKUP.has(k)) armour.push(k);
else if (GEAR_LOOKUP.has(k)) gear.push(k);
else throw new Error(`${where}: ${spec.name} issued unknown item "${k}"`);
}
return { ...spec, label: R.label, blurb: R.blurb, skills, weapons, armour, gear };
}
/* ---------------- actor factory ---------------- */ /* ---------------- actor factory ---------------- */
+20
View File
@@ -448,6 +448,26 @@ const checks = [
["skillBaseFrom(junk) is refused", RULES.skillBaseFrom("dex*2; drop",{dex:{value:13}}), 0], ["skillBaseFrom(junk) is refused", RULES.skillBaseFrom("dex*2; drop",{dex:{value:13}}), 0],
["levelRank('critical')", RULES.levelRank("critical"), 4], ["levelRank('critical')", RULES.levelRank("critical"), 4],
["levelRank(unknown reads as failure)", RULES.levelRank("nonesuch"), 1], ["levelRank(unknown reads as failure)", RULES.levelRank("nonesuch"), 1],
// d100 resolution. Moved out of ringbrp.mjs so node can reach it; these are the
// spot-checks it never had while it lived in the engine.
["applyDifficulty(65, average)", RULES.applyDifficulty(65, "average"), 65],
["applyDifficulty(65, easy) doubles", RULES.applyDifficulty(65, "easy"), 130],
["applyDifficulty(65, difficult) halves", RULES.applyDifficulty(65, "difficult"), 32],
["resolveBands(65).critical", RULES.resolveBands(65).critical, 4],
["resolveBands(65).special", RULES.resolveBands(65).special, 13],
["resolveBands(65).fumbleStart", RULES.resolveBands(65).fumbleStart, 99],
// the 1% floor: a skill beaten below zero still has a chance, and it is not zero
["resolveBands(0) floors the band at 1", RULES.resolveBands(0).band, 1],
["resolveBands(-40) floors too", RULES.resolveBands(-40).band, 1],
["resolveBands caps the band at 100", RULES.resolveBands(140).band, 100],
["gradeRoll(1 of 65) is critical", RULES.gradeRoll(1, RULES.resolveBands(65)), "critical"],
["gradeRoll(13 of 65) is special", RULES.gradeRoll(13, RULES.resolveBands(65)), "special"],
["gradeRoll(65 of 65) is success", RULES.gradeRoll(65, RULES.resolveBands(65)), "success"],
["gradeRoll(66 of 65) is failure", RULES.gradeRoll(66, RULES.resolveBands(65)), "failure"],
// 96–99 never succeed, however good the rating
["gradeRoll(97 of 98) still fails", RULES.gradeRoll(97, RULES.resolveBands(98)), "failure"],
// and 00 always fumbles
["gradeRoll(100 of 98) fumbles", RULES.gradeRoll(100, RULES.resolveBands(98)), "fumble"],
["defenceOutcomeFor(crit, crit)", RULES.defenceOutcomeFor("critical","critical").turnedAside, true], ["defenceOutcomeFor(crit, crit)", RULES.defenceOutcomeFor("critical","critical").turnedAside, true],
["defenceOutcomeFor(crit, special)", RULES.defenceOutcomeFor("critical","special").landsAt, "success"], ["defenceOutcomeFor(crit, special)", RULES.defenceOutcomeFor("critical","special").landsAt, "success"],
["defenceOutcomeFor(crit, success)", RULES.defenceOutcomeFor("critical","success").landsAt, "special"], ["defenceOutcomeFor(crit, success)", RULES.defenceOutcomeFor("critical","success").landsAt, "special"],
+96
View File
@@ -0,0 +1,96 @@
/**
* Turning a posting or a trade into a full character.
*
* A roster agent is written as a job and a rank — `{ role: "lead", rank: "Senior Field
* Officer" }` — and carries no skills and no kit of its own. Everything it can actually
* do is derived from the register at build time by this function, which is why the
* roster cannot drift from the generator: change TRADE_BANDS and every doctor changes
* with it.
*
* This lived inside build-packs.mjs, where only the build could reach it. The lethality
* harness needs the SAME expansion — a simulated Marta Holloway with no skills is not
* Marta Holloway, she is a civilian with her hands up — and build-packs.mjs cannot be
* imported to get at it because importing it runs the build. So it moved here, byte for
* byte, and both sides import it. There is no second copy.
*/
import {
SKILL_CATALOGUE, WEAPONS, ARMOURS, GEAR, DEVICES
} from "./content.mjs";
const WEAPON_LOOKUP = new Map(WEAPONS.map(w => [w.key, w]));
const ARMOUR_LOOKUP = new Map(ARMOURS.map(a => [a.key, a]));
const GEAR_LOOKUP = new Map(GEAR.map(g => [g.key, g]));
const REG = await import("../postings.mjs");
const midBand = b => Math.round((b[0] + b[1]) / 2);
export function expandFromRegister(spec, { where = "roster", civilian = false } = {}) {
const { ROLES, TRADES, INDUCTION, TRADE_BANDS, STANDING_ISSUE, TIER_ISSUE, TIERS } = REG;
const isTrade = !!spec.trade;
const R = isTrade ? TRADES[spec.trade] : ROLES[spec.role];
if (!R) throw new Error(`${where}: no ${isTrade ? "trade" : "role"} "${spec.trade ?? spec.role}"`);
const tierKey = { "Probationary": "probationary", "Field Officer": "officer",
"Senior Field Officer": "senior", "Case Officer": "veteran" }[spec.rank]
?? "officer";
const T = TIERS[tierKey];
const coreV = isTrade ? midBand(TRADE_BANDS.core) : midBand(T.core);
const supportV = isTrade ? midBand(TRADE_BANDS.support) : midBand(T.support);
const inducV = midBand(INDUCTION.bandByTier[tierKey] ?? INDUCTION.band);
const skills = [];
const seen = new Set();
const addSkill = (id, val) => {
if (seen.has(id)) return; // induction never demotes a trade
seen.add(id);
const [fam, sp = ""] = id.split(":");
skills.push({ fam, spec: sp, val });
};
for (const k of R.core) addSkill(k, coreV);
for (const k of R.support) addSkill(k, supportV);
// Induction is a FLOOR for everyone the department employs, posting or trade — see the
// note in ringbrp.mjs. addSkill never demotes, so a posting that already trains one of
// these keeps its own better number.
if (!civilian) for (const k of INDUCTION.skills) addSkill(k, inducV);
if (!civilian) addSkill("language:trade_cant", 55);
// Floors only ever RAISE a rating, so a posting already better stays better.
for (const [id, floor] of Object.entries(spec.floors ?? {})) {
const [fam, sp = ""] = id.split(":");
const found = skills.find(x => x.fam === fam && x.spec === sp);
if (found) found.val = Math.max(found.val, floor);
else { skills.push({ fam, spec: sp, val: floor }); seen.add(id); }
}
// A civilian has never been near a crossing, so nothing anomalous and nothing that
// costs Coherence to use — the criminal's trade kit contains a black mirror, which is
// exactly the sort of thing you acquire AFTER the night the department noticed you.
const isAnomalous = k => {
const it = GEAR_LOOKUP.get(k) ?? WEAPON_LOOKUP.get(k) ?? ARMOUR_LOOKUP.get(k);
return !!it && (it.era === "anomalous" || !!it.coherence);
};
// No firearm and no body armour either. The police trade kit contains a sidearm and a
// stab vest, which is right for a serving officer and wrong for the person before the
// department found them — this is Britain, and the two ex-police pregens were walking
// into a civilian prologue armed and armoured. The department issues those. Playing
// LAST ADMISSION found it: its only fight was over in one round because a punter had
// a revolver. A criminal's knife stays; a knife is not an issued weapon.
const isIssuedForce = k => {
const w = WEAPON_LOOKUP.get(k);
if (w) return w.cls !== "melee";
return ARMOUR_LOOKUP.has(k);
};
const kitKeys = civilian
? [...R.kit, ...(spec.extraWeapons ?? []), ...(spec.extraGear ?? [])]
.filter(k => !isAnomalous(k) && !isIssuedForce(k))
: [...STANDING_ISSUE, ...R.kit,
...(isTrade ? INDUCTION.kit : []),
...(spec.extraWeapons ?? []), ...(spec.extraGear ?? []),
...(TIER_ISSUE[tierKey] ?? [])];
const weapons = [], armour = [], gear = [];
for (const k of new Set(kitKeys)) {
if (WEAPON_LOOKUP.has(k)) weapons.push(k);
else if (ARMOUR_LOOKUP.has(k)) armour.push(k);
else if (GEAR_LOOKUP.has(k)) gear.push(k);
else throw new Error(`${where}: ${spec.name} issued unknown item "${k}"`);
}
return { ...spec, label: R.label, blurb: R.blurb, skills, weapons, armour, gear };
}
+365
View File
@@ -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)}
`);
}