The sheet header said 9 while the body said 4 (R-256)

Verifying R-255 on a live sheet found the rule working everywhere it was built and
contradicted in the first place a GM looks. The header's ARMOUR field is the hide the
creature HAS; on a grounded beast the hit-location table under it read 4 and the header
still read 9. Nothing computes off that field, so nothing in the code was wrong. It was
wrong at the table.

The field stays editable and stays 9 — that is the creature's hide and what a GM types
into. Under it, when system.grounded is set, the sheet now says what is in play:
DOWN: 4 · CHEST 9, derived from exposedHideFor rather than from a second opinion, with
the reason on the tooltip. Verified live: no badge in the air, and grounded the badge and
the location table agree at 4 with the forequarters at 9.

check-rules forbids redefining a rule by NAME, which misses how this goes wrong in
practice — nobody redefines exposedHideFor, they write Math.floor(hide / 2) in the sheet
because it is cheaper than an import. I wrote that version first and check-rules passed
it. It now fails any file outside rules.mjs that halves a hide inline, naming file and
line; check files are exempt because stating the expected value independently is what a
test is for.

That guard also turned up something I have not fixed, recorded in R-256: armourAgainst
lives in ringbrp.mjs rather than rules.mjs, and tools/simulate.mjs has its own inline copy
of it. They agree today so no published number is wrong, but every figure in the bestiary
and the lethality baseline comes out of that simulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-12 23:29:41 +01:00
co-authored by Claude Opus 5
parent c0d33ee81d
commit f847cb2171
8 changed files with 76 additions and 3 deletions
+1 -1
View File
@@ -183,7 +183,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-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, 1016 keys, no leaf/branch collisions
check-lang: OK — en.json, 1018 keys, no leaf/branch collisions
check-templates: OK — 19 templates compile
check-behaviour: OK — 76 behavioural tests
check-scenarios: OK — 10 scenario files, 78 mechanic tags, every skill named resolves against 62 catalogue entries
+32
View File
@@ -5363,3 +5363,35 @@ generated and validated against `npm run check`, and three sentences around it s
"the eight guards" while nine ran. `update-readme` now rewrites the count word as well as
the block. The comment warning that a hardcoded list goes stale was sitting four lines
above the prose that had gone stale.
## R-256 — the header said 9 while the body said 4
Verifying R-255 on a live sheet showed the rule working everywhere it was built and
contradicted in the one place a GM actually looks first. The header's ARMOUR field is the
hide the creature HAS; grounded, the hit-location table underneath it read 4 and the
header still read 9. Nothing computes off that field, so nothing was wrong in the code —
it was wrong at the table, which is the only place that counts.
The field stays editable and stays 9, because 9 is the creature's hide and that is what a
GM needs to type into. Underneath it, when `system.grounded` is set, the sheet now states
what is in play: **DOWN: 4 · CHEST 9**, from `exposedHideFor` rather than from a second
opinion, with the reason on the tooltip.
**The guard, and what it caught on the way.** check-rules forbids redefining a rule BY
NAME, which misses how this actually goes wrong: nobody redefines `exposedHideFor`, they
write `Math.floor(hide / 2)` in the sheet because it is one keystroke cheaper than an
import. I wrote the wrong version first to prove the point and check-rules passed it
happily. It now fails any file outside `rules.mjs` that halves a hide inline, with the
file and line; the check files are exempt, because independently stating the expected
value is what a test is for.
**Open, and worse than what I fixed.** Writing that guard turned up two copies of a
different armour rule. `armourAgainst` — critical ignores armour, special halves it —
lives in `ringbrp.mjs:414`, not in `rules.mjs`, so the authority never covered it; and
`tools/simulate.mjs:267` has its own inline version rather than importing it. They agree
today, so no published number is wrong. But every figure in BESTIARY.md and the whole
lethality baseline comes out of the simulator, so the day someone changes how a special
hit meets armour, the game changes and the measurements do not, and the guards will all
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.
+2
View File
@@ -201,6 +201,8 @@
"RINGBRP.Sheet.MajorWound": "Major wound",
"RINGBRP.Sheet.MajorWoundHint": "A single blow dealing this much, after armour, causes a Major Wound.",
"RINGBRP.Sheet.Armour": "Armour",
"RINGBRP.Sheet.GroundedHide": "down: {body} · chest {vital}",
"RINGBRP.Sheet.GroundedHideHint": "It is on the ground. Half the hide is no longer between you and it — except the chest, which faces you whatever posture it is in.",
"RINGBRP.Sheet.DamageMod": "Damage mod",
"RINGBRP.Sheet.MOV": "MOV",
"RINGBRP.Sheet.DexRank": "DEX rank",
+12
View File
@@ -4713,6 +4713,18 @@ class RingBRPCharacterSheet extends ActorSheet {
ctx.RINGBRP = RINGBRP;
ctx.isCharacter = this.actor.type === "character";
/* R-255. The header field is the hide the beast HAS. Grounded, that is not the hide
that applies, and a GM reading 9 off the top of the sheet while the body reads 4
will use the wrong number — the same defect as a stat block that disagrees with
its actor, one layer in. Say what is in play, from the function the body uses. */
{
const hide = Number(sys.naturalArmour?.value) || 0;
const body = exposedHideFor(hide, sys.grounded, "leg");
ctx.groundedHide = (sys.grounded && hide && body !== hide)
? { body, vital: exposedHideFor(hide, sys.grounded, "vital") }
: null;
}
ctx.skillsByCategory = {};
for (const key of Object.keys(RINGBRP.categories)) ctx.skillsByCategory[key] = [];
for (const it of this.actor.items) {
+6
View File
@@ -228,6 +228,12 @@
width: 44px; text-align: center; font-size: 24px;
background: transparent; border: none; padding: 0; color: var(--rb-white);
}
.rb-grounded-hide {
display: block; margin-top: 2px;
font-family: var(--rb-body); font-size: 9px; font-weight: 700;
letter-spacing: .04em; text-transform: uppercase;
color: var(--rb-danger, #d9534f); white-space: nowrap;
}
.rb-wounded { border-color: var(--rb-danger) !important; }
.rb-wound-flag {
font-family: var(--rb-body); font-size: 10px; font-weight: 700;
+1 -1
View File
@@ -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.10",
"version": "1.7.11",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+3 -1
View File
@@ -13,7 +13,9 @@
<input type="number" name="system.hp.value" value="{{sys.hp.value}}"/><span class="rb-slash">/</span><span class="rb-max">{{sys.hp.max}}</span></div>
<div class="rb-mw"><span class="rb-vital-label">{{localize "RINGBRP.Sheet.MajorWound"}}</span><span class="rb-big">{{sys.majorWound.value}}</span></div>
<div class="rb-armour"><span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
<input type="number" name="system.naturalArmour.value" value="{{sys.naturalArmour.value}}"/></div>
<input type="number" name="system.naturalArmour.value" value="{{sys.naturalArmour.value}}"/>
{{#if groundedHide}}<span class="rb-grounded-hide"
data-tooltip="{{localize 'RINGBRP.Sheet.GroundedHideHint'}}">{{localize "RINGBRP.Sheet.GroundedHide" body=groundedHide.body vital=groundedHide.vital}}</span>{{/if}}</div>
{{!-- The body plan. Every NPC has always HAD hit locations; nothing on this
sheet ever let a GM choose which body they belonged to, so every bear and
every dragon in the game was quietly a man with two arms. --}}
+19
View File
@@ -42,6 +42,25 @@ 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. */
{
const inlineHalving = /Math\.(?:floor|round|ceil)\(\s*[^()]*\b(?:hide|naturalArmour)\b[^()]*\/\s*2/i;
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`);
});
}
}
/* 2. aliases must be identity-equal ---------------------------------------- *
* REQUIRED is authoritative and hand-held on purpose. Discovery alone is not
* enough: the original bug replaced an alias with an arrow function, which a