One armourAgainst, in the authority, shared with the harness (R-257)
Left open by R-256. Two rules decided how a hit meets armour — armourAgainst (a critical
ignores it, a special halves it, rounded down) and damageAfterArmour (what is left never
goes below zero). Both lived in ringbrp.mjs, which is the game. Neither lived in
rules.mjs, which is the authority, so the guard that forbids redefining a rule had
nothing to forbid. tools/simulate.mjs applied armour from its own inline copies of both,
as it had since it was written.
They agreed, which is the whole point. Nothing published was wrong and no guard could
have said they were two things rather than one. But every number in BESTIARY.md, every
row of the lethality baseline and every measurement quoted from R-251 onward comes out of
that harness: change how a special hit meets armour and the game changes, the numbers
describing the game do not, and all nine guards still pass. Same shape as ddc4f99, with
no tolerance to blame and no reason it would ever have surfaced.
Both rules now live in rules.mjs. ringbrp.mjs imports and re-exports them because they
are public API at game.ringbrp. The simulator imports them. Verified live in the world
that game.ringbrp.armourAgainst and the rules.mjs export are the same function object,
not two that agree.
Proof it changed nothing: check-lethality replays 47 creatures over 2000 fights each and
every one fights exactly as recorded — no tolerance, no drift — across roughly four
million resolved attacks, all of which now go through the moved rule.
check-rules fails any file outside rules.mjs that halves armour or subtracts it inline,
naming file and line. Negative-tested by restoring the harness's original three lines
verbatim (both patterns fire) and by redefining armourAgainst in ringbrp.mjs, which the
name-based guard now catches because the rule finally lives somewhere it belongs to.
Arriving in rules.mjs also tripped the spot-check requirement immediately: eight new
spot-checks, and what a special does to hide 9 is now written down once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bb10613992
commit
c3a337314f
@@ -181,7 +181,7 @@ The build runs all nine guards before it writes anything:
|
||||
|
||||
<!-- guards:start -->
|
||||
```
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-13
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+29
-10
@@ -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],
|
||||
|
||||
+6
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user