diff --git a/ringbrp.mjs b/ringbrp.mjs
index c8ae68e..6657dab 100644
--- a/ringbrp.mjs
+++ b/ringbrp.mjs
@@ -24,7 +24,8 @@ import {
locationMaxHp, reflexRating, awarenessRating, encumbranceFrom, reactionBaseFrom,
reactionLoadFrom, reactionPrintedFrom, woundEffectsFor,
BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor,
- locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom,
+ locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, remapLocationDamage,
+ locationDamageReplacement,
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom,
@@ -439,6 +440,53 @@ export function resolveHit({ level, rolled, armour, majorWoundThreshold }) {
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} → ${m.to}`).join(", ");
+ ChatMessage.create({
+ speaker: ChatMessage.getSpeaker({ actor: this }),
+ content: `
${this.name}: body plan changed
`
+ + `
${game.i18n.localize(LOCATION_TABLES[from]?.label ?? from)} → `
+ + `${game.i18n.localize(LOCATION_TABLES[to]?.label ?? to)}
`
+ + `
Wounds carried across: ${lines}.
`
+ + (lost > 0 ? `
${lost} point(s) could not be placed — `
+ + `the destination was already at its destroyed limit.
` : "")
+ + `
`,
+ whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
+ });
+ }
+ return result;
+ }
+
prepareDerivedData() {
super.prepareDerivedData();
const sys = this.system;
@@ -6236,7 +6284,8 @@ Hooks.once("init", () => {
TIERS, THREATS, SPECIES,
npcTarget, npcShouldWithdraw, declareNpcTarget,
setReaction,
- locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor
+ locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor,
+ remapLocationDamage, locationDamageReplacement
};
});
diff --git a/rules.mjs b/rules.mjs
index a23d25c..67853e0 100644
--- a/rules.mjs
+++ b/rules.mjs
@@ -573,6 +573,113 @@ export function locationsFor(speciesId = "baseline") {
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.
*
diff --git a/system.json b/system.json
index 2df0bc2..b566da9 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.0",
+ "version": "1.7.1",
"compatibility": {
"minimum": 13,
"verified": "14.364"
diff --git a/tools/check-anatomy.mjs b/tools/check-anatomy.mjs
index e43e525..b2b8c0e 100644
--- a/tools/check-anatomy.mjs
+++ b/tools/check-anatomy.mjs
@@ -17,13 +17,19 @@
* 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 } from "../rules.mjs";
+import { locationEffectsFor, destructionOutcomeFor, remapLocationDamage,
+ locationDamageReplacement, locationMaxHp } from "../rules.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
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) {
console.error("check-anatomy: FAILED");
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, `
+ `${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`);
diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs
index bef24f1..cc830bd 100644
--- a/tools/check-rules.mjs
+++ b/tools/check-rules.mjs
@@ -503,6 +503,38 @@ const checks = [
["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 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",
RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true],
["an opened body cavity bleeds, as its text says",