From ca040b7926b5c264ac05aaa79df89b933879ef89 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:57:31 +0100 Subject: [PATCH] The belly is not armoured (R-254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-95 made losing a wing change the animal. R-96 took the hide off the wing so losing one was possible. Separately each was right and each was measured. Together they gave the party a soft target and 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 grounding the creature took the party's chance of winning from 44.9% to 1.4%, while its own tactics told the GM that grounding it was the fight they could actually win. Found by measuring something else. Every aimed-shot configuration made the party worse, and the most aggressive was the worst: aiming at a wing at -20% grounds it in 99% of fights and HALVES the win rate to 10.1%. Buying the objective reliably was the fastest way to lose, which only makes sense if the objective is a trap. So no aimed-shot rule; the problem was never the lack of one. It also retires the "+31 points" I reported for grounding at 1.7.3. That was win-rate-if-grounded against win-rate-if-not — selection bias, since the parties that grounded it were the ones shooting well. Forcing the grounding gives the causal value and it was -43. A correlation of +31 and a causation of -43 out of the same mechanic. The fix: a heraldic beast is armoured the way it is drawn, across the back and the flanks. On its belly half that hide is no longer in the way, so exposedHideFor halves natural armour while system.grounded is set. Hide 7 becomes 3. flying grounded worth on borough ground 45.3% 51.7% +6.4 off borough ground 90.6% 97.9% +7.3 Modest deliberately. Grounding should help, not decide — the borough is still the real answer. Losing its damage modifier was worth +26.5 and would have made the wing the whole fight. check-anatomy gains a sixth property: a flyer must not be harder to hurt on the ground than in the air. The first version probed at a single 4-point round and declared the fix broken, because 4 sits inside the one band where a bare wing beats a halved hide — below 5 damage the creature really is better off grounded, above it much worse. Integrating across 1-12 gives 2.83 flying against 3.75 grounded and reads it correctly. Negative-tested against 1.7.8's behaviour, which it names. The lethality baseline is unchanged: its simulation never grounds anything, so system.grounded never comes up there. Co-Authored-By: Claude Opus 5 --- docs/REVIEW_LOG.md | 59 +++++++++++++++++++++++++++++++++++++++++ ringbrp.mjs | 14 ++++++---- rules.mjs | 21 +++++++++++++++ system.json | 2 +- template.json | 1 + tools/check-anatomy.mjs | 45 ++++++++++++++++++++++++++++++- tools/check-rules.mjs | 6 +++++ 7 files changed, 141 insertions(+), 7 deletions(-) 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.