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>
97 lines
4.9 KiB
JavaScript
97 lines
4.9 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|