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:
co-authored by
Claude Opus 5
parent
c3002e93c9
commit
2c547efe72
+5
-39
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user