Files
RingBRP/tools/check-anatomy.mjs
T
slaguru666andClaude Opus 5 ca040b7926 The belly is not armoured (R-254)
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 <noreply@anthropic.com>
2026-09-12 22:57:31 +01:00

327 lines
16 KiB
JavaScript

/**
* check-anatomy — a body plan must be complete, drawable, and consequential.
*
* The hit location system was in the game for a long time as a hazards-only
* subsystem: falling, fire, explosions and automatic fire rolled a location, and
* every ordinary sword blow and pistol shot did not. Nothing caught that, because
* every individual piece was correct — the tables, the armour coverage, the
* disable and destroy thresholds, the effects. They simply were not joined up.
*
* This guard covers the part a table notices first:
*
* 1. Every table covers 1-20 exactly once in BOTH modes. A gap is a location
* roll that returns nothing; an overlap is a location that cannot be hit.
* 2. Every location has a shape in BODY_ART, and every shape has a location.
* The sheet filters shapeless locations out silently, so a missing shape is
* a limb that is on the table and not on the drawing.
* 3. Every `kind` used by any table is one locationEffectsFor actually handles,
* has effect text to print, and has a declared destruction outcome. A kind
* nobody handles falls through to "body" and quietly does nothing.
* 4. A wound survives a change of body plan. locationDamage is keyed by location
* id, and only the current plan's keys are ever read, so every plan-to-plan
* move must land every wound on a location the destination actually has.
* Caught live: a Barghest switched to quadruped kept 5 points in `legR` and 7
* in `chest` — 12 hit points of damage under keys nothing answers to.
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { LOCATION_TABLES, BODY_ART } from "../anatomy.mjs";
import { locationEffectsFor, destructionOutcomeFor, remapLocationDamage,
locationDamageReplacement, locationMaxHp, groundedPlanFor,
naturalArmourOn, exposedHideFor } from "../rules.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const problems = [];
/* The kinds the engine prints effect text for. Read out of the source rather than
duplicated here, for the same reason rules.mjs exists: one definition. */
const engine = readFileSync(join(ROOT, "ringbrp.mjs"), "utf8");
const effectBlock = engine.slice(engine.indexOf("RINGBRP.locationEffects = {"));
const EFFECT_KINDS = new Set(
[...effectBlock.slice(0, effectBlock.indexOf("};")).matchAll(/^\s*(\w+):\s*\{/gm)]
.map(m => m[1]));
/* Kinds locationEffectsFor genuinely acts on — probed rather than parsed, so this
cannot drift from the function's real behaviour. */
function kindDoesSomething(kind) {
const base = locationEffectsFor([]);
const hit = locationEffectsFor([{ kind, disabled: true, destroyed: false }]);
return JSON.stringify(base) !== JSON.stringify(hit);
}
let locCount = 0;
for (const [id, table] of Object.entries(LOCATION_TABLES)) {
const art = BODY_ART[id];
if (!art) { problems.push(`${id}: no BODY_ART entry — the sheet would draw nothing`); continue; }
// 1. d20 coverage, both modes
for (const mode of ["melee", "ranged"]) {
const faces = new Array(21).fill(0);
for (const loc of table.locations) {
const [lo, hi] = loc[mode] ?? [];
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < 1 || hi > 20 || hi < lo) {
problems.push(`${id}.${loc.id}: ${mode} range [${lo}, ${hi}] is not a 1-20 span`);
continue;
}
for (let f = lo; f <= hi; f++) faces[f]++;
}
const gaps = [], dupes = [];
for (let f = 1; f <= 20; f++) {
if (faces[f] === 0) gaps.push(f);
else if (faces[f] > 1) dupes.push(f);
}
if (gaps.length) problems.push(`${id} ${mode}: d20 face(s) ${gaps.join(",")} hit nothing`);
if (dupes.length) problems.push(`${id} ${mode}: d20 face(s) ${dupes.join(",")} hit two locations`);
}
// 2. table and drawing agree, both ways
const tableIds = new Set(table.locations.map(l => l.id));
const artIds = new Set(Object.keys(art.parts ?? {}));
for (const l of tableIds) if (!artIds.has(l)) problems.push(`${id}.${l}: on the table, not on the figure`);
for (const a of artIds) if (!tableIds.has(a)) problems.push(`${id}.${a}: on the figure, not on the table`);
if (!/^[\d.\s-]+$/.test(String(art.viewBox ?? ""))) {
problems.push(`${id}: viewBox "${art.viewBox}" is not four numbers`);
}
// 3. every kind is handled, printable and has a destruction outcome
for (const loc of table.locations) {
locCount++;
if (!(Number(loc.frac) > 0)) problems.push(`${id}.${loc.id}: frac ${loc.frac} gives the location no hit points`);
if (!EFFECT_KINDS.has(loc.kind)) {
problems.push(`${id}.${loc.id}: kind "${loc.kind}" has no entry in RINGBRP.locationEffects `
+ `— it would fall back to "body" and print the wrong effect`);
}
if (!kindDoesSomething(loc.kind)) {
problems.push(`${id}.${loc.id}: kind "${loc.kind}" changes nothing in locationEffectsFor `
+ `— losing it would be cosmetic`);
}
if (typeof destructionOutcomeFor(loc.kind, true) !== "string") {
problems.push(`${id}.${loc.id}: kind "${loc.kind}" has no destruction outcome`);
}
}
}
/* 4. a change of body plan keeps every wound, and keeps what it MEANT --------- *
* Two separate promises. Every wound must land on a location the destination actually
* has (the Barghest defect: 12 points under keys nothing answers to). And it must land
* with the same severity, because severity is what the rules read — a location is
* disabled at its maximum and destroyed at twice it, and those maxima differ between
* plans, so carrying the raw number across silently healed destroyed limbs one way and
* destroyed merely-disabled ones the other. */
const HP = 16;
const lmax = frac => locationMaxHp(HP, frac);
const severity = (taken, max) => taken >= max * 2 ? "destroyed" : taken >= max ? "disabled" : "hurt";
let pairs = 0, carried = 0;
for (const [fromId, fromT] of Object.entries(LOCATION_TABLES)) {
for (const [toId, toT] of Object.entries(LOCATION_TABLES)) {
if (fromId === toId) continue;
pairs++;
const valid = new Set(toT.locations.map(l => l.id));
for (const band of ["hurt", "disabled", "destroyed"]) {
const src = {};
for (const l of fromT.locations) {
const m = lmax(l.frac);
src[l.id] = band === "hurt" ? Math.max(1, m - 1) : band === "disabled" ? m : m * 2;
}
const { damage, moved } = remapLocationDamage(src, fromId, toId, HP);
if (moved.length !== fromT.locations.length) {
problems.push(`${fromId}->${toId} [${band}]: ${fromT.locations.length} wounds in, `
+ `${moved.length} carried — the rest vanished without being reported`);
}
for (const id of Object.keys(damage)) {
if (!valid.has(id)) {
problems.push(`${fromId}->${toId} [${band}]: wound placed on "${id}", not a ${toId} `
+ `location — it would be stranded exactly like the Barghest's`);
}
}
// Side and height only mean something between bilateral plans. A vesh has one
// grasping limb and a cadence is five interchangeable units, so they have fewer
// parts than a vertebrate has and the last limbs must land wherever there is
// room. Demanding an anatomical correspondence there would be demanding fiction.
const sideOf = sl => (/R$/.test(sl) ? "R" : /L$/.test(sl) ? "L" : "");
const highOf = sl => (/^(upper|wing)/.test(sl) ? "upper" : /^lower/.test(sl) ? "lower" : "");
const bilateral = t => t.locations.some(l => sideOf(l.slot));
const paired = bilateral(fromT) && bilateral(toT);
for (const mv of moved) {
carried++;
const sFrom = sideOf(fromT.locations.find(l => l.id === mv.from)?.slot ?? "");
const sTo = sideOf(toT.locations.find(l => l.id === mv.to)?.slot ?? "");
// A sided wound may lose its side (the destination may have no pair) but must
// never swap it: the arm you shot must not come back as the other arm.
if (paired && sFrom && sTo && sFrom !== sTo) {
problems.push(`${fromId}->${toId} [${band}]: ${mv.from} (${sFrom}) arrives at `
+ `${mv.to} (${sTo}) — a change of shape must not swap left and right`);
}
// Nor top and bottom: a wing and an arm are fore limbs and must not come back
// as hind legs.
const hFrom = highOf(fromT.locations.find(l => l.id === mv.from)?.slot ?? "");
const hTo = highOf(toT.locations.find(l => l.id === mv.to)?.slot ?? "");
if (paired && hFrom && hTo && hFrom !== hTo) {
problems.push(`${fromId}->${toId} [${band}]: ${mv.from} (${hFrom}) arrives at `
+ `${mv.to} (${hTo}) — a fore limb must not become a hind one`);
}
const S = fromT.locations.find(l => l.id === mv.from);
const D = toT.locations.find(l => l.id === mv.to);
if (!D) continue;
const before = severity(src[mv.from], lmax(S.frac));
const after = severity(damage[mv.to], lmax(D.frac));
if (before !== after) {
problems.push(`${fromId}->${toId} [${band}]: ${mv.from} was ${before}, `
+ `arrives at ${mv.to} ${after} — a change of shape must not wound or heal`);
}
}
// The update that carries this must not delete a wound it is also setting.
const upd = locationDamageReplacement(src, damage);
for (const id of Object.keys(damage)) {
if (`-=${id}` in upd) {
problems.push(`${fromId}->${toId}: the update both sets and deletes "${id}" `
+ `— Foundry applies the deletion and the wound is thrown away`);
}
}
}
// Slots are what make the pairing symmetric; a duplicate would make it arbitrary.
const slots = toT.locations.map(l => l.slot);
for (const l of toT.locations) {
if (!l.slot) problems.push(`${toId}.${l.id}: no slot — it cannot be paired across plans`);
}
if (new Set(slots).size !== slots.length) {
problems.push(`${toId}: duplicate slot(s) ${slots.filter((v, i) => slots.indexOf(v) !== i).join(",")}`);
}
}
}
/* 5. a flyer's landing must be somewhere it can survive ---------------------- *
* A plan that declares `grounded` promises that losing a wing turns the creature into
* that other plan mid-fight. The landing has to be real: a plan that exists, that has
* no wings of its own (or the creature would be grounded and still flying), and that
* can take every wound the flyer was carrying without inventing or losing one. The
* wings' OWN damage is deliberately dropped by the engine — a ruined wing must not
* arrive as a ruined foreleg — so it is excluded here too. */
let flyers = 0;
for (const [id, table] of Object.entries(LOCATION_TABLES)) {
const landing = groundedPlanFor(id);
if (!landing) continue;
flyers++;
const dest = LOCATION_TABLES[landing];
if (!dest) { problems.push(`${id}: grounded plan "${landing}" does not exist`); continue; }
if (!table.locations.some(l => l.kind === "wing")) {
problems.push(`${id}: declares a grounded plan but has no wing to lose`);
}
if (dest.locations.some(l => l.kind === "wing")) {
problems.push(`${id}: grounded plan "${landing}" still has wings — it would come `
+ `down and be airborne again`);
}
if (groundedPlanFor(landing)) {
problems.push(`${id}: grounded plan "${landing}" is itself a flyer — landing must end`);
}
// The trigger has to be REACHABLE. This is the defect that started all of this: the
// supporter's wings sat under the same six points of hide as the rest of it, so the
// eight damage needed to fold one almost never arrived and its written rule fired in
// 9% of fights. A plan whose whole behaviour turns on losing a wing must not armour
// that wing with the creature's own hide.
for (const l of table.locations) {
if (l.kind === "wing" && !l.bare) {
problems.push(`${id}.${l.id}: a flyer's wing carries the creature's hide, so the `
+ `grounded rule needs it beaten through full natural armour — mark it bare or `
+ `the rule is decoration`);
}
if (l.bare && naturalArmourOn(l, 9) !== 0) {
problems.push(`${id}.${l.id}: marked bare but still takes natural armour`);
}
}
// every wound it could be carrying, wings excluded, must survive the drop
const src = {};
for (const l of table.locations) if (l.kind !== "wing") src[l.id] = locationMaxHp(16, l.frac);
const { damage, moved } = remapLocationDamage(src, id, landing, 16);
const valid = new Set(dest.locations.map(l => l.id));
if (moved.length !== Object.keys(src).length) {
problems.push(`${id}->${landing}: ${Object.keys(src).length} wounds in, ${moved.length} carried`);
}
for (const k of Object.keys(damage)) {
if (!valid.has(k)) problems.push(`${id}->${landing}: wound stranded on "${k}"`);
}
// and the landing must have room: two wounds collapsing onto one location loses a
// distinction the creature had, even when the severity survives.
const targets = new Set(moved.map(m => m.to));
if (targets.size !== moved.length) {
const collapsed = moved.length - targets.size;
problems.push(`${id}->${landing}: ${collapsed} wound(s) collapse onto a location `
+ `already taken — the landing has ${dest.locations.length} places for `
+ `${moved.length} wounds`);
}
// and it must not be a healing: a disabled location stays disabled across the drop
for (const mv of moved) {
const D = dest.locations.find(l => l.id === mv.to);
if (D && damage[mv.to] < locationMaxHp(16, D.frac)) {
problems.push(`${id}->${landing}: ${mv.from} was disabled and arrives at ${mv.to} `
+ `merely hurt — coming down must not heal it`);
}
}
}
/* 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);
process.exit(1);
}
console.log(`check-anatomy: OK — ${Object.keys(LOCATION_TABLES).length} body plans, `
+ `${locCount} locations, d20 complete in both modes, every location drawn, `
+ `every kind handled and destructible, ${pairs} plan changes carried ${carried} `
+ `wounds with their severity intact, `
+ `${flyers} flyer(s) with a survivable landing`);