From 2c547efe72e7c85fe12b47e027e38e75f2c3ee2c Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:10:05 +0100 Subject: [PATCH] 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 --- package.json | 1 + ringbrp.mjs | 44 +---- rules.mjs | 52 ++++++ tools/build-packs.mjs | 76 +-------- tools/check-rules.mjs | 20 +++ tools/expand-spec.mjs | 96 +++++++++++ tools/simulate.mjs | 365 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 543 insertions(+), 111 deletions(-) create mode 100644 tools/expand-spec.mjs create mode 100644 tools/simulate.mjs diff --git a/package.json b/package.json index 4c01a0b..b7f3421 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "node tools/build-packs.mjs && node tools/update-readme.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", "test": "npm run check", "readme": "node tools/update-readme.mjs" diff --git a/ringbrp.mjs b/ringbrp.mjs index 00894f3..675051c 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -27,6 +27,7 @@ import { encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, + applyDifficulty, resolveBands, gradeRoll, DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY, destructionOutcomeFor, RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor, 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. */ -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"; -} +/* applyDifficulty, resolveBands and gradeRoll now live in rules.mjs — they are pure + rules and the lethality harness has to reach them from node, which cannot import this + file. Re-exported here so every existing caller and import path is unchanged. */ +export { applyDifficulty, resolveBands, gradeRoll }; /** Spec §4.4. Returns {band, mod} or {band:"beyond", mod:null} when impossible. */ diff --git a/rules.mjs b/rules.mjs index 49300da..8bde499 100644 --- a/rules.mjs +++ b/rules.mjs @@ -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)); } +/** + * 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 order to compare two rolls against each other. diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs index 0ea42b5..bc86d68 100644 --- a/tools/build-packs.mjs +++ b/tools/build-packs.mjs @@ -188,78 +188,10 @@ function deviceItem(d) { * cant is the department's own argot and a civilian has never heard it spoken. */ const REG = await import("../postings.mjs"); -const midBand = b => Math.round((b[0] + b[1]) / 2); - -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 }; -} +/* 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 + of this logic is not the agent that gets packed. */ +import { expandFromRegister } from "./expand-spec.mjs"; /* ---------------- actor factory ---------------- */ diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 92fe21e..3c883f1 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -448,6 +448,26 @@ const checks = [ ["skillBaseFrom(junk) is refused", RULES.skillBaseFrom("dex*2; drop",{dex:{value:13}}), 0], ["levelRank('critical')", RULES.levelRank("critical"), 4], ["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, special)", RULES.defenceOutcomeFor("critical","special").landsAt, "success"], ["defenceOutcomeFor(crit, success)", RULES.defenceOutcomeFor("critical","success").landsAt, "special"], diff --git a/tools/expand-spec.mjs b/tools/expand-spec.mjs new file mode 100644 index 0000000..06dbd6c --- /dev/null +++ b/tools/expand-spec.mjs @@ -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 }; +} diff --git a/tools/simulate.mjs b/tools/simulate.mjs new file mode 100644 index 0000000..3561ee9 --- /dev/null +++ b/tools/simulate.mjs @@ -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 [,] [--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)} +`); + +}