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
+5 -39
View File
@@ -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. */