diff --git a/README.md b/README.md index 8ee1bfc..f13a2aa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 2f6b53a..aa9e97e 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -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. diff --git a/lang/en.json b/lang/en.json index a841174..e088df6 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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", diff --git a/ringbrp.mjs b/ringbrp.mjs index 1e8db71..70f2e3b 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -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) { diff --git a/styles/ringbrp.css b/styles/ringbrp.css index 0a51b43..c053c48 100644 --- a/styles/ringbrp.css +++ b/styles/ringbrp.css @@ -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; diff --git a/system.json b/system.json index 3c31e19..7770f07 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.10", + "version": "1.7.11", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/templates/npc-sheet.hbs b/templates/npc-sheet.hbs index 9eb8ac2..c13c53b 100644 --- a/templates/npc-sheet.hbs +++ b/templates/npc-sheet.hbs @@ -13,7 +13,9 @@ /{{sys.hp.max}}
{{localize "RINGBRP.Sheet.MajorWound"}}{{sys.majorWound.value}}
{{localize "RINGBRP.Sheet.Armour"}} -
+ + {{#if groundedHide}}{{localize "RINGBRP.Sheet.GroundedHide" body=groundedHide.body vital=groundedHide.vital}}{{/if}} {{!-- 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. --}} diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 9588c5e..273ee7b 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -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