diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md
index 08fdb11..e50c586 100644
--- a/docs/REVIEW_LOG.md
+++ b/docs/REVIEW_LOG.md
@@ -5253,3 +5253,62 @@ the whole nine-guard suite 2.55s
Ten times the statistical work for one and a half seconds. The guard had been carrying a
tolerance of six points, and then an exact comparison against figures that could move
eleven, for want of a measurement that cost less than the time to read this paragraph.
+
+### R-254. Grounding it was a forty-three point mistake
+
+Two commits in a row shipped a defect that neither of their guards could see, and it
+took an unrelated question — what would an aimed shot at −30% be worth? — to find it.
+
+R-95 made losing a wing change the animal. R-96 took the hide off the wing so that
+losing one was possible at all. Separately each was right and each was measured. Together
+they gave the party a soft target and then **deleted it the moment the party hit it**:
+
+```
+winged 6/20 ranged faces land on armour 0 (a bare wing)
+quadruped 0/20
+```
+
+A service round on borough ground averages 3.75 after halving: 3.75 through a bare wing,
+0 through hide 7. So putting the creature on the ground took the party's chance of
+winning from **44.9% to 1.4%** — and its own tactics tell the GM that grounding it is
+"the fight the agents can actually win".
+
+The aimed shot measurement is what exposed it. Every configuration made the party worse,
+and the worst was the most aggressive: aiming at a wing at −20% grounds the creature in
+99% of fights and **halves** the win rate to 10.1%. Buying the objective reliably was the
+fastest way to lose. That only makes sense if the objective is a trap, and it was.
+
+It also retires a number I reported at 1.7.3: that grounding was "worth +31 points",
+measured as win-rate-if-grounded against win-rate-if-not. That was selection bias —
+parties that managed to ground it were the ones shooting well. Forcing the grounding
+reveals the causal value, and it was deeply negative. A correlation of +31 and a
+causation of −43 out of the same mechanic is worth remembering the next time one of
+these numbers looks encouraging.
+
+**The fix.** A heraldic beast is armoured the way it is drawn: across the back and the
+flanks. Down on its belly, half that hide is no longer between you and it, so
+`exposedHideFor` halves natural armour while `system.grounded` is set. Hide 7 becomes 3,
+which restores something an ordinary round can get through without pretending a grounded
+animal is harmless.
+
+| | flying | grounded | grounding is worth |
+|---|---|---|---|
+| on borough ground | 45.3% | 51.7% | **+6.4** |
+| off borough ground | 90.6% | 97.9% | **+7.3** |
+
+Modest on purpose. Grounding it should help, not decide; the borough is still the real
+answer. Of the options measured, losing its damage modifier entirely would have been
+worth +26.5 and made the wing the whole fight.
+
+**The guard, and the two mistakes in building it.** check-anatomy now measures what an
+average round gets through before and after, and fails a flyer that is harder to hurt on
+the ground than in the air. The first version probed at a single 4-point round and
+reported that the fix had failed — because 4 is inside the one narrow band where a bare
+wing beats a halved hide. Below 5 damage the creature really is better off on the ground;
+above it, much worse. Integrating across the range 1–12 gives 2.83 flying against 3.75
+grounded and reads the design correctly.
+
+It is also deliberately one-sided: it catches grounding being a setback, not grounding
+being too generous. Stripping the hide to zero passes it. That is a balance question and
+this is a correctness guard, and conflating the two is how the six-point tolerance in
+check-lethality came to hide a real change for four commits.
diff --git a/ringbrp.mjs b/ringbrp.mjs
index 1bd1d9b..c985f9f 100644
--- a/ringbrp.mjs
+++ b/ringbrp.mjs
@@ -25,7 +25,7 @@ import {
reactionLoadFrom, reactionPrintedFrom, woundEffectsFor,
BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor,
locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, remapLocationDamage,
- groundedPlanFor, naturalArmourOn,
+ groundedPlanFor, naturalArmourOn, exposedHideFor,
locationDamageReplacement,
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
@@ -578,7 +578,8 @@ class RingBRPActor extends Actor {
// --- Armour: what is worn, and what each piece actually reaches ---
// Every piece used to add to every location, so a Brodie helmet protected your
// legs. Pieces now declare coverage and the total is computed per location.
- const naturalArmour = Number(sys.naturalArmour?.value) || 0;
+ // Down on its belly, half the hide is no longer between you and the animal.
+ const naturalArmour = exposedHideFor(sys.naturalArmour?.value, sys.grounded);
// A shield is armour you HOLD: its points count only while it is raised and only
// while it is still a shield. Lowered or broken it is weight and nothing else.
const wornArmour = this.items.filter(i => i.type === "armour" && i.system.equipped)
@@ -1111,7 +1112,7 @@ export async function hitLocationRoll({ target, damage, mode = "ranged",
const locArmour = live
? Number(live.armour) || 0
: locationArmourFor((Number(loc.armour) || 0)
- + naturalArmourOn(loc, sys.naturalArmour?.value),
+ + naturalArmourOn(loc, exposedHideFor(sys.naturalArmour?.value, sys.grounded)),
sys.wornArmour ?? [], loc.kind);
effective = Math.max(0, effective - armourAgainst(level, locArmour));
}
@@ -1201,7 +1202,10 @@ export async function hitLocationRoll({ target, damage, mode = "ranged",
const wings = (target.system.locations ?? []).filter(l => l.kind === "wing").map(l => l.id);
const drop = Object.fromEntries(wings.map(id => [`system.locationDamage.-=${id}`, null]));
if (Object.keys(drop).length) await target.update(drop);
- await target.update({ "system.speciesProfile": grounded });
+ // The flag, not the wing wounds, is what says it is down: once the wings leave
+ // its table there is nothing left to read them off, and being grounded lasts the
+ // rest of the scene rather than as long as a location does.
+ await target.update({ "system.speciesProfile": grounded, "system.grounded": true });
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: target }),
content: `
${
@@ -6324,7 +6328,7 @@ Hooks.once("init", () => {
npcTarget, npcShouldWithdraw, declareNpcTarget,
setReaction,
locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor,
- remapLocationDamage, locationDamageReplacement, groundedPlanFor
+ remapLocationDamage, locationDamageReplacement, groundedPlanFor, exposedHideFor
};
});
diff --git a/rules.mjs b/rules.mjs
index 79a2431..6575ee3 100644
--- a/rules.mjs
+++ b/rules.mjs
@@ -597,6 +597,27 @@ 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.
*/
+/**
+ * The hide a creature still has between you and it once it is on the ground.
+ *
+ * Built to fix a defect in the two commits before it. R-95 made losing a wing change
+ * the animal and R-96 made the wing hittable by taking the hide off the membrane —
+ * and together they handed the party a soft target whose destruction REMOVES the soft
+ * target. Measured: six of twenty ranged faces land on a bare wing while it flies and
+ * none do once it is grounded, so putting it on the ground took the party's chance of
+ * winning from 44.9% to 1.4%. Grounding it was a 43-point mistake, and the creature's
+ * own tactics called it "the fight the agents can actually win".
+ *
+ * A heraldic beast is armoured the way a heraldic beast is drawn: across the back and
+ * the flanks. Down on its belly it is not, so half the hide stops being between you and
+ * it. That restores something an ordinary round can get through, which is the thing
+ * grounding took away, and it does it without pretending a grounded animal is harmless.
+ */
+export function exposedHideFor(naturalArmour = 0, grounded = false) {
+ const a = Number(naturalArmour) || 0;
+ return grounded ? Math.floor(a / 2) : a;
+}
+
export function naturalArmourOn(location, naturalArmour = 0) {
return location?.bare ? 0 : (Number(naturalArmour) || 0);
}
diff --git a/system.json b/system.json
index 4a3d789..be34d5b 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.8",
+ "version": "1.7.9",
"compatibility": {
"minimum": 13,
"verified": "14.364"
diff --git a/template.json b/template.json
index 4a68026..0f8eae9 100644
--- a/template.json
+++ b/template.json
@@ -107,6 +107,7 @@
"naturalArmour": {
"value": 0
},
+ "grounded": false,
"tactics": "",
"dying": {
"rounds": 0,
diff --git a/tools/check-anatomy.mjs b/tools/check-anatomy.mjs
index 5b9afb6..bfa4853 100644
--- a/tools/check-anatomy.mjs
+++ b/tools/check-anatomy.mjs
@@ -30,7 +30,7 @@ import { fileURLToPath } from "node:url";
import { LOCATION_TABLES, BODY_ART } from "../anatomy.mjs";
import { locationEffectsFor, destructionOutcomeFor, remapLocationDamage,
locationDamageReplacement, locationMaxHp, groundedPlanFor,
- naturalArmourOn } from "../rules.mjs";
+ naturalArmourOn, exposedHideFor } from "../rules.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const problems = [];
@@ -270,6 +270,49 @@ for (const [id, table] of Object.entries(LOCATION_TABLES)) {
}
}
+/* 6. coming down must not be a reward for the creature ----------------------- *
+ * The defect this exists to catch shipped twice before anyone measured it. R-95 made a
+ * lost wing change the animal; R-96 took the hide off the wing so the wing could be lost
+ * at all. Together they gave the party a soft target and then deleted it the moment the
+ * party hit it: six of twenty ranged faces land on a bare wing while it flies and none
+ * do once it is grounded, so the creature's own "disable either wing and it is the
+ * fight you can actually win" was worth minus forty-three points of win rate.
+ *
+ * So: measure what an average round of ordinary fire gets through, before and after.
+ * A flyer that is HARDER to hurt on the ground than in the air fails, whatever the
+ * fiction says about it. */
+{
+ /* Averaged across the whole range a round in this game can do — 1d3 to 1d10+2, before
+ and after the borough halves it — because the answer genuinely changes with the size
+ of the round and a single probe picks its own conclusion. A 4-point round does more
+ through a bare wing than through a halved hide; a 9-point round does far less. The
+ first version of this check measured only at 4 and duly reported that the fix had
+ failed. Small arms prefer it airborne; anything heavier wants it down. */
+ const HIDE = 7, RANGE = 12;
+ const through = (planId, grounded) => {
+ const t = LOCATION_TABLES[planId];
+ let total = 0;
+ for (let shot = 1; shot <= RANGE; shot++) {
+ for (let face = 1; face <= 20; face++) {
+ const loc = t.locations.find(l => face >= l.ranged[0] && face <= l.ranged[1]);
+ if (!loc) continue;
+ total += Math.max(0, shot - naturalArmourOn(loc, exposedHideFor(HIDE, grounded)));
+ }
+ }
+ return total / (20 * RANGE);
+ };
+ for (const [id] of Object.entries(LOCATION_TABLES)) {
+ const landing = groundedPlanFor(id);
+ if (!landing || !LOCATION_TABLES[landing]) continue;
+ const air = through(id, false), ground = through(landing, true);
+ if (ground < air) {
+ problems.push(`${id}: an average round gets ${air.toFixed(2)} through it flying and only `
+ + `${ground.toFixed(2)} once grounded — putting it on the ground would make it HARDER `
+ + `to kill, and its own tactics tell the GM the opposite`);
+ }
+ }
+}
+
if (problems.length) {
console.error("check-anatomy: FAILED");
for (const p of [...new Set(problems)]) console.error(" " + p);
diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs
index 7567249..91c26d7 100644
--- a/tools/check-rules.mjs
+++ b/tools/check-rules.mjs
@@ -548,6 +548,12 @@ const checks = [
RULES.naturalArmourOn({ kind: "wing" }, 7), 7],
["naturalArmourOn(no location at all) keeps the hide",
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.
+ ["exposedHideFor(flying) is the whole hide", RULES.exposedHideFor(7, false), 7],
+ ["exposedHideFor(grounded) is half of it", RULES.exposedHideFor(7, true), 3],
+ ["exposedHideFor(grounded, thin hide)", RULES.exposedHideFor(1, true), 0],
+ ["exposedHideFor(no hide at all)", RULES.exposedHideFor(0, true), 0],
// A deletion and an assignment of the same key do not both apply: the deletion wins.
// quadruped and winged share every location but the wings, so this is where a wound
// was silently thrown away instead of carried.