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.