diff --git a/README.md b/README.md index f13a2aa..cc7352e 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ The build runs all nine guards before it writes anything: ``` -check-rules: OK — 108 rules, 34 files scanned, 3 aliases + 46 constant sets checked, 387 formulas verified +check-rules: OK — 110 rules, 34 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct check-lang: OK — en.json, 1018 keys, no leaf/branch collisions check-templates: OK — 19 templates compile diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index aa9e97e..4958d30 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -5395,3 +5395,44 @@ hit meets armour, the game changes and the measurements do not, and the guards w still pass. That is R-251 with a longer fuse. Not fixed here: moving a rule into `rules.mjs` and re-pointing the simulator at it is a change to the measurement pipeline and wants its own before-and-after. + +## R-257 — the harness had its own copy of how armour works + +Left open by R-256 and fixed here. Two rules governed how a hit meets armour: + +- `armourAgainst` — a critical ignores armour, a special halves it, rounded down +- `damageAfterArmour` — what is left never goes below zero + +Both lived in `ringbrp.mjs`, which is the game, and neither was in `rules.mjs`, which is +the authority — so the guard that forbids redefining a rule had nothing to forbid. And +`tools/simulate.mjs` applied armour from its own inline copies of both, as it had since +it was written. + +**They agreed.** That is the whole point. Nothing published was ever wrong, and no guard +could have told you they were two things rather than one. But every number in +BESTIARY.md, every row of the lethality baseline and every measurement quoted in this log +from R-251 onward comes out of that harness. Change how a special hit meets armour and +the game changes; the numbers that describe the game do not; all nine guards pass. That +is exactly the shape of ddc4f99, which changed the game and left the measurements behind +for four commits — except that this one had no six-point tolerance to blame, and would +have hidden indefinitely. + +**The fix.** Both rules moved into `rules.mjs`. `ringbrp.mjs` imports and re-exports them, +because they are part of the public API at `game.ringbrp` and macros call them. The +simulator imports them. Verified live in the world that `game.ringbrp.armourAgainst` and +`rules.mjs`'s export are the same function object, not two that agree. + +**Proof it changed nothing.** check-lethality compares 47 creatures over 2000 fights each +against the recorded baseline, and every one of them fights *exactly* as recorded — no +tolerance, no drift. A refactor of the rule the harness applies to every single hit, +across roughly four million resolved attacks, moved nothing. That is what makes it a +refactor rather than a change. + +**The guard.** check-rules now fails any file outside `rules.mjs` that halves armour or +subtracts it inline, naming file and line; check files are exempt. Negative-tested by +restoring the harness's original three lines verbatim — both fire — and by redefining +`armourAgainst` in `ringbrp.mjs`, which the older name-based guard now catches because +the rule finally lives somewhere it can be said to belong to. The two new exports also +tripped the spot-check requirement the moment they arrived, which is the authority +working as designed: eight new spot-checks, and the numbers for a special hit against +hide 9 are now stated in exactly one place. diff --git a/ringbrp.mjs b/ringbrp.mjs index 70f2e3b..bc24a42 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -26,7 +26,7 @@ import { BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor, locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, remapLocationDamage, groundedPlanFor, naturalArmourOn, exposedHideFor, - locationDamageReplacement, + locationDamageReplacement, armourAgainst, damageAfterArmour, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, @@ -405,18 +405,11 @@ export function styleRangeBonus(style, band) { /** Spec §3. Hit points and the floored Major Wound threshold. */ -/** Spec §3. Damage after armour never goes below zero. */ -export function damageAfterArmour(rolled, armour) { - return Math.max(0, (Number(rolled) || 0) - (Number(armour) || 0)); -} - -/** Spec R-39. Armour treatment by attack success level. */ -export function armourAgainst(level, armour) { - const a = Number(armour) || 0; - if (level === "critical") return 0; // ignores armour entirely - if (level === "special") return Math.floor(a / 2); // counts as half - return a; -} +/* armourAgainst and damageAfterArmour moved to rules.mjs (R-257) so the lethality + harness shares them instead of restating them — the same reason locationFor and + resolveLocationHit went. Re-exported here because they are part of the public API + at game.ringbrp and macros in the wild call them. */ +export { armourAgainst, damageAfterArmour }; /** * Spec R-39 + §3. Applies a resolved attack to a target and reports whether it diff --git a/rules.mjs b/rules.mjs index c9b0110..800a9c9 100644 --- a/rules.mjs +++ b/rules.mjs @@ -597,6 +597,30 @@ export function groundedPlanFor(speciesId = "baseline") { * wearing. WORN armour still covers it, so barding a wing works exactly as before; * it is the animal's own hide that stops at the edge of the wing. */ +/** + * WHAT ARMOUR DOES AGAINST A HIT, AND WHAT IS LEFT OF THE HIT AFTERWARDS. + * + * Both of these lived in `ringbrp.mjs` — the game — and the lethality harness, which + * produces every number in BESTIARY.md and the whole baseline, carried its own inline + * copies of both. They agreed, so nothing published was wrong; they were two statements + * of the same rule with nothing holding them together, which is the arrangement that + * let ddc4f99 change the game and leave the measurements behind for four commits + * (R-251). A rule the measurements do not share is a rule the measurements cannot + * check. So the authority owns them and both callers import them. + */ +/** Spec R-39. Armour treatment by attack success level. */ +export function armourAgainst(level, armour) { + const a = Number(armour) || 0; + if (level === "critical") return 0; // ignores armour entirely + if (level === "special") return Math.floor(a / 2); // counts as half + return a; +} + +/** Spec §3. Damage after armour never goes below zero. */ +export function damageAfterArmour(rolled, armour) { + return Math.max(0, (Number(rolled) || 0) - (Number(armour) || 0)); +} + /** * The hide a creature still has between you and it once it is on the ground. * diff --git a/system.json b/system.json index 7770f07..4fe949b 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "ringbrp", "title": "The Custodians", "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.", - "version": "1.7.11", + "version": "1.7.12", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 273ee7b..479aa7c 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -42,21 +42,29 @@ for (const rel of files) { } } -/* 1b. and none of them may re-derive the hide ------------------------------- * - * Redefinition is caught by NAME above, which misses the way this actually goes - * wrong: the sheet header wanted the grounded hide and `Math.floor(hide / 2)` is - * one keystroke cheaper than importing exposedHideFor. Then the header and the hit - * locations are two rules that happen to agree, until one of them is changed. The - * exemption is the check files, where stating the expected value independently is - * the entire point of a test. */ +/* 1b. and none of them may re-derive how armour works ---------------------- * + * Redefinition is caught by NAME above, which misses both ways this actually goes + * wrong. The sheet header wanted the grounded hide and `Math.floor(hide / 2)` is one + * keystroke cheaper than importing exposedHideFor. And the lethality harness applied + * armour with its own inline copy of armourAgainst and damageAfterArmour for as long + * as it existed (R-257) — agreeing with the game the whole time, which is precisely + * what makes it dangerous: every number in BESTIARY.md and the baseline comes out of + * that harness, so a rule changed in one place and not the other changes the game + * while the measurements describing it stay still, and every guard here still passes. + * The exemption is the check files, where stating the expected value independently is + * the entire point of a test. */ { - const inlineHalving = /Math\.(?:floor|round|ceil)\(\s*[^()]*\b(?:hide|naturalArmour)\b[^()]*\/\s*2/i; + const REIMPLEMENTED = [ + [/Math\.(?:floor|round|ceil)\(\s*[^()]*\b(?:hide|naturalArmour|armour|armor)\b[^()]*\/\s*2/i, + "halves armour inline — armourAgainst() and exposedHideFor() own that"], + [/Math\.max\(\s*0\s*,[^;]*\b(?:armour|armor)\b[^;]*\)/i, + "subtracts armour inline — damageAfterArmour() owns that"] + ]; for (const rel of files) { if (rel.startsWith("tools/check-")) continue; const src = await readFile(new URL(rel, root), "utf8"); src.split("\n").forEach((line, i) => { - if (inlineHalving.test(line)) - fail(`${rel}:${i + 1} halves natural armour inline — exposedHideFor() owns that`); + for (const [re, why] of REIMPLEMENTED) if (re.test(line)) fail(`${rel}:${i + 1} ${why}`); }); } } @@ -569,6 +577,17 @@ const checks = [ RULES.naturalArmourOn(null, 7), 7], // On its belly, half the hide is no longer in the way — which is what gives an // ordinary round somewhere to land once grounding takes the bare wings off the table. + // R-257. The harness applied these two from its own copy for as long as it has + // existed. The numbers below are the ones a special hit against hide 9 produces, and + // they are now the only statement of them anywhere. + ["armourAgainst(critical) ignores it all", RULES.armourAgainst("critical", 9), 0], + ["armourAgainst(special) counts half", RULES.armourAgainst("special", 9), 4], + ["armourAgainst(special) floors the half", RULES.armourAgainst("special", 5), 2], + ["armourAgainst(success) is the full plate", RULES.armourAgainst("success", 9), 9], + ["armourAgainst(no armour at all)", RULES.armourAgainst("special", 0), 0], + ["damageAfterArmour(through)", RULES.damageAfterArmour(12, 4), 8], + ["damageAfterArmour(stopped) never goes under zero", RULES.damageAfterArmour(3, 9), 0], + ["damageAfterArmour(exactly stopped)", RULES.damageAfterArmour(9, 9), 0], ["exposedHideFor(flying) is the whole hide", RULES.exposedHideFor(9, false, "leg"), 9], ["exposedHideFor(grounded) is half of it", RULES.exposedHideFor(9, true, "leg"), 4], ["exposedHideFor(grounded VITAL) is exempt", RULES.exposedHideFor(9, true, "vital"), 9], diff --git a/tools/simulate.mjs b/tools/simulate.mjs index b39ff39..f0f7a9e 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -50,7 +50,7 @@ import { resolveBands, gradeRoll, defenceOutcomeFor, defencePenaltyFor, conditionFor, dyingLimitFor, REACTION, locationFor, resolveLocationHit, locationsFor, locationMaxHp, - locationEffectsFor, woundPenaltyFrom + locationEffectsFor, woundPenaltyFrom, armourAgainst, damageAfterArmour } from "../rules.mjs"; import { WEAPONS, ARMOURS, NPCS, PREGENS } from "./content.mjs"; import { ROSTER } from "./roster.mjs"; @@ -265,11 +265,12 @@ function attack(rng, attacker, defender) { 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; + // R-257: armourAgainst and damageAfterArmour come from rules.mjs, the same two the + // game applies. This harness used to restate both inline; they agreed, which is + // exactly how a rule change reaches the game and not the numbers that describe it. + const armour = armourAgainst(landing, defender.armour); - const taken = Math.max(0, dmg - armour); + const taken = damageAfterArmour(dmg, armour); if (taken <= 0) return; /* Locate it. 1d20 against the defender's own species table, then the damage goes to