Carry wounds across a change of body plan (R-94)

locationDamage is keyed by location id and prepareDerivedData reads only
the keys belonging to the current plan, so changing speciesProfile on a
wounded actor stranded every wound it had. Found on a live Barghest that
had been shot in the leg and chest as a humanoid and then switched to
quadruped: 5 points under `legR` and 7 under `chest`, exactly its 12
missing hit points, sitting under keys no quadruped location answers to.
It read on the sheet as a badly hurt dog with eight pristine locations.

Stored and never read — the same defect this system keeps producing.

rules.mjs gains remapLocationDamage, which carries wounds across by KIND,
because kind is what the rules act on. Exact-kind matches are claimed
first in table order, so the obvious pairings land before any fallback
competes for them; that ordering is what makes the arms become the
forelegs going one way and the forelegs become the arms coming back.
Overflow past a location's destroyed ceiling is reported, not dropped.

It also gains locationDamageReplacement, which exists because the first
version of this fix had the bug it was fixing. Foundry merges object
updates so the outgoing keys must be deleted by name (R-93), but 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 switching between them emitted `-=hindLegR` alongside `hindLegR: 5`
and threw the wound away. Caught in live testing, not by reading.

Guards, both negative-tested before being wired in:
  - check-anatomy now walks all 20 ordered plan pairs and asserts every
    wound lands on a location the destination has, the arithmetic closes,
    the round trip does not leak, and no update both sets and deletes the
    same key.
  - check-rules gains 13 spot-checks over both new rules.

Verified in a running Foundry: baseline -> quadruped -> winged ->
quadruped -> baseline conserves all 12 points with no orphans, returns
to exactly {legR: 5, chest: 7}, and whispers the GM a receipt of what
moved each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-12 16:29:16 +01:00
co-authored by Claude Opus 5
parent ddc4f99ff4
commit a90c4f3e71
5 changed files with 265 additions and 5 deletions
+51 -2
View File
@@ -24,7 +24,8 @@ import {
locationMaxHp, reflexRating, awarenessRating, encumbranceFrom, reactionBaseFrom, locationMaxHp, reflexRating, awarenessRating, encumbranceFrom, reactionBaseFrom,
reactionLoadFrom, reactionPrintedFrom, woundEffectsFor, reactionLoadFrom, reactionPrintedFrom, woundEffectsFor,
BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor, BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor,
locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, remapLocationDamage,
locationDamageReplacement,
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom,
@@ -439,6 +440,53 @@ export function resolveHit({ level, rolled, armour, majorWoundThreshold }) {
class RingBRPActor extends Actor { class RingBRPActor extends Actor {
/**
* Carry wounds across a change of body plan.
*
* locationDamage is keyed by location id and prepareDerivedData reads only the keys
* belonging to the current plan, so switching speciesProfile on a wounded actor left
* every existing wound in the document unreadable — R-93 all over again, except the
* orphan here is a limb rather than a flag. Found on a live Barghest that had been
* shot in the leg and chest as a humanoid: 12 points of damage, exactly its missing
* hit points, stranded under keys no quadruped location answers to.
*
* The remap is a rule and lives in rules.mjs. This only spots the change, applies the
* result to the same update, and says out loud what it moved — silently rearranging a
* GM's wounded creature is not something to do without a receipt.
*/
async _preUpdate(changed, options, user) {
const result = await super._preUpdate(changed, options, user);
if (result === false) return result;
const to = changed?.system?.speciesProfile;
const from = this.system?.speciesProfile || "baseline";
if (!to || to === from) return result;
const current = this.system?.locationDamage ?? {};
if (!Object.values(current).some(v => (Number(v) || 0) > 0)) return result;
const { damage, moved, lost } = remapLocationDamage(current, from, to,
Number(this.system?.hp?.max) || 0);
changed.system.locationDamage = locationDamageReplacement(current, damage);
if (moved.length) {
const lines = moved.map(m => `${m.from} ${m.points} &rarr; ${m.to}`).join(", ");
ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this }),
content: `<div class="rb-card"><h3>${this.name}: body plan changed</h3>`
+ `<p>${game.i18n.localize(LOCATION_TABLES[from]?.label ?? from)} &rarr; `
+ `${game.i18n.localize(LOCATION_TABLES[to]?.label ?? to)}</p>`
+ `<p>Wounds carried across: ${lines}.</p>`
+ (lost > 0 ? `<p><strong>${lost}</strong> point(s) could not be placed — `
+ `the destination was already at its destroyed limit.</p>` : "")
+ `</div>`,
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
});
}
return result;
}
prepareDerivedData() { prepareDerivedData() {
super.prepareDerivedData(); super.prepareDerivedData();
const sys = this.system; const sys = this.system;
@@ -6236,7 +6284,8 @@ Hooks.once("init", () => {
TIERS, THREATS, SPECIES, TIERS, THREATS, SPECIES,
npcTarget, npcShouldWithdraw, declareNpcTarget, npcTarget, npcShouldWithdraw, declareNpcTarget,
setReaction, setReaction,
locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor,
remapLocationDamage, locationDamageReplacement
}; };
}); });
+107
View File
@@ -573,6 +573,113 @@ export function locationsFor(speciesId = "baseline") {
return (LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline).locations; return (LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline).locations;
} }
/**
* WHERE A WOUND GOES WHEN THE BODY CHANGES SHAPE.
*
* `locationDamage` is keyed by location id, and prepareDerivedData only reads the keys
* belonging to the CURRENT body plan. So changing speciesProfile on a wounded actor
* stranded every wound it had: a Barghest switched from baseline to quadruped kept 5
* points in `legR` and 7 in `chest`, which no quadruped location is called, and read on
* the sheet as a dog 12 hit points down with eight pristine locations. Stored, never
* read — the same defect this system keeps producing.
*
* Wounds are carried across by KIND, because kind is what the rules act on: a leg is
* what you limp on whichever plan names it, a vital is what kills you. Exact-kind
* matches are claimed first, in table order, so the obvious pairings land before any
* fallback competes for them; only then do the leftovers walk the fallback chain.
*
* That ordering is what makes arms become forelegs. Going baseline -> quadruped the two
* legs take the two hind legs, and the arms — finding no `arm` and no `grasp` — fall to
* `leg` and take the forelegs that are left. Coming back, the forelegs become arms. The
* limb you lost stays the limb you lost.
*/
const KIND_FALLBACK = {
head: ["neck", "ridge", "vital", "body", "unit"],
neck: ["head", "vital", "body", "unit"],
vital: ["body", "ridge", "unit"],
body: ["vital", "unit"],
leg: ["brace", "arm", "body", "unit"],
arm: ["grasp", "leg", "brace", "body", "unit"],
wing: ["arm", "leg", "body", "unit"],
brace: ["leg", "arm", "body", "unit"],
grasp: ["arm", "leg", "body", "unit"],
ridge: ["head", "vital", "body", "unit"],
unit: ["body", "vital", "head", "leg", "arm"]
};
/**
* Rebuild a locationDamage map for a new body plan.
*
* Returns the new map, the moves it made, and how much damage could not be placed —
* `lost` is only ever non-zero when a target is already at its destroyed ceiling (2x
* max) or the target plan has no locations at all. The caller reports it rather than
* discarding it quietly, which is the whole point of the exercise.
*/
/**
* Build the update that REPLACES a location-damage map.
*
* Foundry merges object updates, so the outgoing plan's keys must be deleted by name or
* the actor carries both plans' wounds at once (R-93). But a deletion and an assignment
* of the SAME key in one update do not both apply — the deletion wins — and the plans
* that share ids are exactly where that bites: quadruped and winged share every location
* but the wings, so a switch between them emitted `-=hindLegR` alongside `hindLegR: 5`
* and silently threw the wound away. Delete only what the new map does not set.
*/
export function locationDamageReplacement(current = {}, next = {}) {
const upd = { ...next };
for (const id of Object.keys(current)) if (!(id in next)) upd[`-=${id}`] = null;
return upd;
}
export function remapLocationDamage(damage = {}, fromId = "baseline", toId = "baseline", totalHp = 0) {
const from = (LOCATION_TABLES[fromId] ?? LOCATION_TABLES.baseline).locations;
const to = (LOCATION_TABLES[toId] ?? LOCATION_TABLES.baseline).locations;
const out = {}, moved = [];
let lost = 0;
const wounded = from.filter(l => (Number(damage[l.id]) || 0) > 0);
if (!to.length) {
for (const l of wounded) lost += Number(damage[l.id]) || 0;
return { damage: out, moved, lost };
}
const used = new Set();
const freeOfKind = kind => to.find(t => t.kind === kind && !used.has(t.id));
const anyOfKind = kind => to.find(t => t.kind === kind);
const assign = (src, tgt) => {
used.add(tgt.id);
moved.push({ from: src.id, to: tgt.id, points: Number(damage[src.id]) || 0 });
out[tgt.id] = (out[tgt.id] || 0) + (Number(damage[src.id]) || 0);
};
const pending = [];
// Pass 1 — exact kind, unclaimed. The obvious pairings, before fallbacks compete.
for (const src of wounded) {
const tgt = freeOfKind(src.kind);
if (tgt) assign(src, tgt); else pending.push(src);
}
// Pass 2 — the fallback chain, still only onto unclaimed locations.
const stillPending = [];
for (const src of pending) {
const tgt = (KIND_FALLBACK[src.kind] ?? []).map(freeOfKind).find(Boolean);
if (tgt) assign(src, tgt); else stillPending.push(src);
}
// Pass 3 — everything is claimed, so stack. A second head wound is still a head wound.
for (const src of stillPending) {
const tgt = [src.kind, ...(KIND_FALLBACK[src.kind] ?? [])].map(anyOfKind).find(Boolean)
?? to[0];
assign(src, tgt);
}
// Nothing may arrive already worse than destroyed.
for (const t of to) {
if (!(t.id in out)) continue;
const ceiling = locationMaxHp(totalHp, t.frac) * 2;
if (out[t.id] > ceiling) { lost += out[t.id] - ceiling; out[t.id] = ceiling; }
}
return { damage: out, moved, lost };
}
/** /**
* How much a battered body costs a roll of one category. * How much a battered body costs a roll of one category.
* *
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp", "id": "ringbrp",
"title": "The Custodians", "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.", "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.0", "version": "1.7.1",
"compatibility": { "compatibility": {
"minimum": 13, "minimum": 13,
"verified": "14.364" "verified": "14.364"
+74 -2
View File
@@ -17,13 +17,19 @@
* 3. Every `kind` used by any table is one locationEffectsFor actually handles, * 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 * has effect text to print, and has a declared destruction outcome. A kind
* nobody handles falls through to "body" and quietly does nothing. * 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 { readFileSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { LOCATION_TABLES, BODY_ART } from "../anatomy.mjs"; import { LOCATION_TABLES, BODY_ART } from "../anatomy.mjs";
import { locationEffectsFor, destructionOutcomeFor } from "../rules.mjs"; import { locationEffectsFor, destructionOutcomeFor, remapLocationDamage,
locationDamageReplacement, locationMaxHp } from "../rules.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const problems = []; const problems = [];
@@ -97,6 +103,72 @@ for (const [id, table] of Object.entries(LOCATION_TABLES)) {
} }
} }
/* 4. no wound is stranded by a change of body plan -------------------------- *
* Every ordered pair, with one point in every location of the source, so each plan's
* every kind is forced to find a home in every other plan. */
const HP = 16;
let pairs = 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 src = Object.fromEntries(fromT.locations.map(l => [l.id, 1]));
const inSum = fromT.locations.length;
const { damage, moved, lost } = remapLocationDamage(src, fromId, toId, HP);
const valid = new Set(toT.locations.map(l => l.id));
for (const id of Object.keys(damage)) {
if (!valid.has(id)) {
problems.push(`${fromId}->${toId}: wound placed on "${id}", which is not a ${toId} location `
+ `— it would be stranded exactly like the Barghest's`);
}
}
if (moved.length !== fromT.locations.length) {
problems.push(`${fromId}->${toId}: ${fromT.locations.length} wounds in, ${moved.length} moved `
+ `— ${fromT.locations.length - moved.length} vanished without being reported`);
}
const outSum = Object.values(damage).reduce((a, b) => a + b, 0);
if (outSum + lost !== inSum) {
problems.push(`${fromId}->${toId}: ${inSum} points in, ${outSum} placed + ${lost} reported lost `
+ `— the arithmetic does not close`);
}
if (lost !== 0) {
problems.push(`${fromId}->${toId}: lost ${lost} point(s) placing one point per location `
+ `— nothing should be unplaceable at this size`);
}
// A wound may never arrive already destroyed.
for (const [id, v] of Object.entries(damage)) {
const t = toT.locations.find(l => l.id === id);
if (t && v > locationMaxHp(HP, t.frac) * 2) {
problems.push(`${fromId}->${toId}: ${id} arrives at ${v}, past its destroyed ceiling`);
}
}
// The update that carries this must not delete a wound it is also setting. Foundry
// applies the deletion, so an overlapping id loses its damage entirely.
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`);
}
}
const kept = Object.fromEntries(Object.entries(upd).filter(([k]) => !k.startsWith("-=")));
if (Object.values(kept).reduce((x, y) => x + y, 0) !== outSum) {
problems.push(`${fromId}->${toId}: the update carries `
+ `${Object.values(kept).reduce((x, y) => x + y, 0)} points, the remap placed ${outSum}`);
}
// And the way back must be just as total.
const back = remapLocationDamage(damage, toId, fromId, HP);
const backSum = Object.values(back.damage).reduce((a, b) => a + b, 0);
if (backSum + back.lost + lost !== inSum) {
problems.push(`${fromId}->${toId}->${fromId}: round trip leaks `
+ `(${inSum} -> ${outSum} -> ${backSum}, ${lost + back.lost} reported lost)`);
}
}
}
if (problems.length) { if (problems.length) {
console.error("check-anatomy: FAILED"); console.error("check-anatomy: FAILED");
for (const p of [...new Set(problems)]) console.error(" " + p); for (const p of [...new Set(problems)]) console.error(" " + p);
@@ -105,4 +177,4 @@ if (problems.length) {
console.log(`check-anatomy: OK — ${Object.keys(LOCATION_TABLES).length} body plans, ` console.log(`check-anatomy: OK — ${Object.keys(LOCATION_TABLES).length} body plans, `
+ `${locCount} locations, d20 complete in both modes, every location drawn, ` + `${locCount} locations, d20 complete in both modes, every location drawn, `
+ `every kind handled and destructible`); + `every kind handled and destructible, ${pairs} plan changes carry every wound`);
+32
View File
@@ -503,6 +503,38 @@ const checks = [
["a winged melee 17 is a wing too", RULES.locationFor(17, "winged", "melee").kind, "wing"], ["a winged melee 17 is a wing too", RULES.locationFor(17, "winged", "melee").kind, "wing"],
["a destroyed neck is death", RULES.destructionOutcomeFor("neck", true), "dead"], ["a destroyed neck is death", RULES.destructionOutcomeFor("neck", true), "dead"],
["a destroyed wing is not", RULES.destructionOutcomeFor("wing", true), "none"], ["a destroyed wing is not", RULES.destructionOutcomeFor("wing", true), "none"],
// A change of body plan must not strand a wound. The exact-kind pass claims the
// obvious pairings first, so the arms are still free to become the forelegs.
["remapLocationDamage(legR) becomes a hind leg",
RULES.remapLocationDamage({ legR: 5 }, "baseline", "quadruped", 16).damage.hindLegR, 5],
["remapLocationDamage(chest) becomes the forequarters",
RULES.remapLocationDamage({ chest: 7 }, "baseline", "quadruped", 16).damage.foreQtr, 7],
["remapLocationDamage(armR) becomes a foreleg",
RULES.remapLocationDamage({ legR: 1, legL: 1, armR: 4 }, "baseline", "quadruped", 16).damage.foreLegR, 4],
["remapLocationDamage(foreLegR) becomes an arm",
RULES.remapLocationDamage({ hindLegR: 1, hindLegL: 1, foreLegR: 4 }, "quadruped", "baseline", 16).damage.armR, 4],
["remapLocationDamage(wingR) becomes an arm",
RULES.remapLocationDamage({ wingR: 3 }, "winged", "baseline", 16).damage.armR, 3],
["remapLocationDamage(head) becomes the vesh ridge",
RULES.remapLocationDamage({ head: 4 }, "baseline", "vesh", 16).damage.ridge, 4],
["remapLocationDamage keeps the total",
Object.values(RULES.remapLocationDamage({ legR: 2, chest: 5, head: 3 }, "baseline", "cadence", 16).damage)
.reduce((a, b) => a + b, 0), 10],
["remapLocationDamage clamps at the destroyed ceiling",
RULES.remapLocationDamage({ head: 99 }, "baseline", "quadruped", 16).damage.head, 14],
["remapLocationDamage reports what it could not place",
RULES.remapLocationDamage({ head: 99 }, "baseline", "quadruped", 16).lost, 85],
["remapLocationDamage on an unchanged plan is identity",
RULES.remapLocationDamage({ chest: 4 }, "baseline", "baseline", 16).damage.chest, 4],
// 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.
["locationDamageReplacement(overlapping id) keeps the value",
RULES.locationDamageReplacement({ hindLegR: 5 }, { hindLegR: 5 }).hindLegR, 5],
["locationDamageReplacement(overlapping id) does not also delete it",
"-=hindLegR" in RULES.locationDamageReplacement({ hindLegR: 5 }, { hindLegR: 5 }), false],
["locationDamageReplacement(dropped id) deletes it",
RULES.locationDamageReplacement({ legR: 5 }, { hindLegR: 5 })["-=legR"], null],
["one lost wing grounds it", ["one lost wing grounds it",
RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true], RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true],
["an opened body cavity bleeds, as its text says", ["an opened body cavity bleeds, as its text says",