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)} +`); + +}