R-254 shipped the belly rule and called +6.4 points "modest on purpose". That read the
wrong statistic. +6.4 measured whether grounding helps; the question was whether the
borough still matters, and the gap between fighting the supporter on borough ground and
off it had fallen from 60.6 points to 44.9. Half damage from every departmental weapon is
meant to be the fight's central problem, and I had quietly taken sixteen points off it
while reporting a small number about something else. Same error as R-251.
Three changes that only work together:
- hide 7 -> 9, restoring the weight on the axis that should hurt
- grounded still halves it, 9 -> 4, which an ordinary round beats
- exposedHideFor now takes the location's kind and leaves `vital` unhalved: a beast on
its belly is not presenting its chest to the floor
Measured: 28.4% on borough ground flying, 68.4% grounded, 84.6% off it — a 56.2 point gap
against 44.9 before. Predicted 28.4 / 84.3 / 55.9 before building.
The composition of the three hide functions lives in prepareDerivedData where no test
could reach it, and check-anatomy's guard 6 is deliberately one-sided so it would not
have caught the vital losing its exemption. check-behaviour now composes the same
expression from the real supporter spec and the real tables. Negative-tested four ways:
removing the exemption, un-baring the wings, removing the halving, and inflating the hide
past what a round can beat each fail it with the right message.
Cost: the supporter goes from fifth to second deadliest creature in the book (2.84 -> 3.38
down, 46.8% -> 69.3% wiped). check-lethality refused the build until that row was
deliberately re-recorded.
BESTIARY.md printed the new hide and said nothing about it halving, because the body-plan
blurb predates the rule. The README said "eight guards" in three sentences above a
generated block listing nine. Both are the signature defect in prose: a number written
down that nothing reads. Both now generate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
16 KiB
JavaScript
328 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 = 9, 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, loc.kind)));
|
|
}
|
|
}
|
|
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`);
|