diff --git a/anatomy.mjs b/anatomy.mjs
index c6376f4..3a11e71 100644
--- a/anatomy.mjs
+++ b/anatomy.mjs
@@ -20,13 +20,13 @@ export const LOCATION_TABLES = {
baseline: {
label: "RINGBRP.Species.Baseline",
locations: [
- { id: "legR", label: "RINGBRP.Loc.LegR", melee: [1, 4], ranged: [1, 3], frac: 0.42, kind: "leg" },
- { id: "legL", label: "RINGBRP.Loc.LegL", melee: [5, 8], ranged: [4, 6], frac: 0.42, kind: "leg" },
- { id: "abdomen", label: "RINGBRP.Loc.Abdomen", melee: [9, 11], ranged: [7, 10], frac: 0.42, kind: "body" },
- { id: "chest", label: "RINGBRP.Loc.Chest", melee: [12, 12], ranged: [11, 15], frac: 0.5, kind: "vital"},
- { id: "armR", label: "RINGBRP.Loc.ArmR", melee: [13, 15], ranged: [16, 17], frac: 0.35, kind: "arm" },
- { id: "armL", label: "RINGBRP.Loc.ArmL", melee: [16, 18], ranged: [18, 19], frac: 0.35, kind: "arm" },
- { id: "head", label: "RINGBRP.Loc.Head", melee: [19, 20], ranged: [20, 20], frac: 0.4, kind: "head" }
+ { id: "legR", label: "RINGBRP.Loc.LegR", melee: [1, 4], ranged: [1, 3], frac: 0.42, kind: "leg" , slot: "lowerR" },
+ { id: "legL", label: "RINGBRP.Loc.LegL", melee: [5, 8], ranged: [4, 6], frac: 0.42, kind: "leg" , slot: "lowerL" },
+ { id: "abdomen", label: "RINGBRP.Loc.Abdomen", melee: [9, 11], ranged: [7, 10], frac: 0.42, kind: "body" , slot: "body" },
+ { id: "chest", label: "RINGBRP.Loc.Chest", melee: [12, 12], ranged: [11, 15], frac: 0.5, kind: "vital", slot: "vital" },
+ { id: "armR", label: "RINGBRP.Loc.ArmR", melee: [13, 15], ranged: [16, 17], frac: 0.35, kind: "arm" , slot: "upperR" },
+ { id: "armL", label: "RINGBRP.Loc.ArmL", melee: [16, 18], ranged: [18, 19], frac: 0.35, kind: "arm" , slot: "upperL" },
+ { id: "head", label: "RINGBRP.Loc.Head", melee: [19, 20], ranged: [20, 20], frac: 0.4, kind: "head" , slot: "head" }
]
},
/* A QUADRUPED — a bear, a horse, a hound, a big cat. Four legs, a barrel divided
@@ -38,14 +38,14 @@ export const LOCATION_TABLES = {
quadruped: {
label: "RINGBRP.Species.Quadruped",
locations: [
- { id: "hindLegR", label: "RINGBRP.Loc.HindLegR", melee: [1, 3], ranged: [1, 2], frac: 0.42, kind: "leg" },
- { id: "hindLegL", label: "RINGBRP.Loc.HindLegL", melee: [4, 6], ranged: [3, 4], frac: 0.42, kind: "leg" },
- { id: "hindQtr", label: "RINGBRP.Loc.HindQtr", melee: [7, 9], ranged: [5, 8], frac: 0.42, kind: "body" },
- { id: "foreQtr", label: "RINGBRP.Loc.ForeQtr", melee: [10, 12], ranged: [9, 13], frac: 0.5, kind: "vital"},
- { id: "foreLegR", label: "RINGBRP.Loc.ForeLegR", melee: [13, 15], ranged: [14, 15], frac: 0.42, kind: "leg" },
- { id: "foreLegL", label: "RINGBRP.Loc.ForeLegL", melee: [16, 18], ranged: [16, 17], frac: 0.42, kind: "leg" },
- { id: "neck", label: "RINGBRP.Loc.Neck", melee: [19, 19], ranged: [18, 19], frac: 0.4, kind: "neck" },
- { id: "head", label: "RINGBRP.Loc.Head", melee: [20, 20], ranged: [20, 20], frac: 0.4, kind: "head" }
+ { id: "hindLegR", label: "RINGBRP.Loc.HindLegR", melee: [1, 3], ranged: [1, 2], frac: 0.42, kind: "leg" , slot: "lowerR" },
+ { id: "hindLegL", label: "RINGBRP.Loc.HindLegL", melee: [4, 6], ranged: [3, 4], frac: 0.42, kind: "leg" , slot: "lowerL" },
+ { id: "hindQtr", label: "RINGBRP.Loc.HindQtr", melee: [7, 9], ranged: [5, 8], frac: 0.42, kind: "body" , slot: "body" },
+ { id: "foreQtr", label: "RINGBRP.Loc.ForeQtr", melee: [10, 12], ranged: [9, 13], frac: 0.5, kind: "vital", slot: "vital" },
+ { id: "foreLegR", label: "RINGBRP.Loc.ForeLegR", melee: [13, 15], ranged: [14, 15], frac: 0.42, kind: "leg" , slot: "upperR" },
+ { id: "foreLegL", label: "RINGBRP.Loc.ForeLegL", melee: [16, 18], ranged: [16, 17], frac: 0.42, kind: "leg" , slot: "upperL" },
+ { id: "neck", label: "RINGBRP.Loc.Neck", melee: [19, 19], ranged: [18, 19], frac: 0.4, kind: "neck" , slot: "neck" },
+ { id: "head", label: "RINGBRP.Loc.Head", melee: [20, 20], ranged: [20, 20], frac: 0.4, kind: "head" , slot: "head" }
]
},
/* A WINGED QUADRUPED — a dragon, a wyvern, a griffon. The quadruped, plus the two
@@ -56,16 +56,16 @@ export const LOCATION_TABLES = {
winged: {
label: "RINGBRP.Species.Winged",
locations: [
- { id: "hindLegR", label: "RINGBRP.Loc.HindLegR", melee: [1, 3], ranged: [1, 2], frac: 0.42, kind: "leg" },
- { id: "hindLegL", label: "RINGBRP.Loc.HindLegL", melee: [4, 6], ranged: [3, 4], frac: 0.42, kind: "leg" },
- { id: "hindQtr", label: "RINGBRP.Loc.HindQtr", melee: [7, 9], ranged: [5, 6], frac: 0.42, kind: "body" },
- { id: "foreQtr", label: "RINGBRP.Loc.ForeQtr", melee: [10, 12], ranged: [7, 9], frac: 0.5, kind: "vital"},
- { id: "foreLegR", label: "RINGBRP.Loc.ForeLegR", melee: [13, 14], ranged: [10, 10], frac: 0.42, kind: "leg" },
- { id: "foreLegL", label: "RINGBRP.Loc.ForeLegL", melee: [15, 16], ranged: [11, 11], frac: 0.42, kind: "leg" },
- { id: "wingR", label: "RINGBRP.Loc.WingR", melee: [17, 17], ranged: [12, 14], frac: 0.35, kind: "wing" },
- { id: "wingL", label: "RINGBRP.Loc.WingL", melee: [18, 18], ranged: [15, 17], frac: 0.35, kind: "wing" },
- { id: "neck", label: "RINGBRP.Loc.Neck", melee: [19, 19], ranged: [18, 19], frac: 0.4, kind: "neck" },
- { id: "head", label: "RINGBRP.Loc.Head", melee: [20, 20], ranged: [20, 20], frac: 0.4, kind: "head" }
+ { id: "hindLegR", label: "RINGBRP.Loc.HindLegR", melee: [1, 3], ranged: [1, 2], frac: 0.42, kind: "leg" , slot: "lowerR" },
+ { id: "hindLegL", label: "RINGBRP.Loc.HindLegL", melee: [4, 6], ranged: [3, 4], frac: 0.42, kind: "leg" , slot: "lowerL" },
+ { id: "hindQtr", label: "RINGBRP.Loc.HindQtr", melee: [7, 9], ranged: [5, 6], frac: 0.42, kind: "body" , slot: "body" },
+ { id: "foreQtr", label: "RINGBRP.Loc.ForeQtr", melee: [10, 12], ranged: [7, 9], frac: 0.5, kind: "vital", slot: "vital" },
+ { id: "foreLegR", label: "RINGBRP.Loc.ForeLegR", melee: [13, 14], ranged: [10, 10], frac: 0.42, kind: "leg" , slot: "upperR" },
+ { id: "foreLegL", label: "RINGBRP.Loc.ForeLegL", melee: [15, 16], ranged: [11, 11], frac: 0.42, kind: "leg" , slot: "upperL" },
+ { id: "wingR", label: "RINGBRP.Loc.WingR", melee: [17, 17], ranged: [12, 14], frac: 0.35, kind: "wing" , slot: "wingR" },
+ { id: "wingL", label: "RINGBRP.Loc.WingL", melee: [18, 18], ranged: [15, 17], frac: 0.35, kind: "wing" , slot: "wingL" },
+ { id: "neck", label: "RINGBRP.Loc.Neck", melee: [19, 19], ranged: [18, 19], frac: 0.4, kind: "neck" , slot: "neck" },
+ { id: "head", label: "RINGBRP.Loc.Head", melee: [20, 20], ranged: [20, 20], frac: 0.4, kind: "head" , slot: "head" }
]
},
/* A VESH is bilaterally ASYMMETRIC: one long grasping limb, one short bracing
@@ -75,12 +75,12 @@ export const LOCATION_TABLES = {
vesh: {
label: "RINGBRP.Species.Vesh",
locations: [
- { id: "footBrace", label: "RINGBRP.Loc.BraceFoot", melee: [1, 3], ranged: [1, 3], frac: 0.42, kind: "leg" },
- { id: "limbBrace", label: "RINGBRP.Loc.BraceLimb", melee: [4, 8], ranged: [4, 7], frac: 0.42, kind: "brace" },
- { id: "lowTrunk", label: "RINGBRP.Loc.LowerTrunk", melee: [9, 12], ranged: [8, 12], frac: 0.42, kind: "body" },
- { id: "upTrunk", label: "RINGBRP.Loc.UpperTrunk", melee: [13, 15], ranged: [13, 15], frac: 0.42, kind: "body" },
- { id: "limbGrasp", label: "RINGBRP.Loc.GraspLimb", melee: [16, 18], ranged: [16, 17], frac: 0.35, kind: "grasp" },
- { id: "ridge", label: "RINGBRP.Loc.Ridge", melee: [19, 20], ranged: [18, 20], frac: 0.5, kind: "ridge" }
+ { id: "footBrace", label: "RINGBRP.Loc.BraceFoot", melee: [1, 3], ranged: [1, 3], frac: 0.42, kind: "leg" , slot: "lower" },
+ { id: "limbBrace", label: "RINGBRP.Loc.BraceLimb", melee: [4, 8], ranged: [4, 7], frac: 0.42, kind: "brace" , slot: "brace" },
+ { id: "lowTrunk", label: "RINGBRP.Loc.LowerTrunk", melee: [9, 12], ranged: [8, 12], frac: 0.42, kind: "body" , slot: "body" },
+ { id: "upTrunk", label: "RINGBRP.Loc.UpperTrunk", melee: [13, 15], ranged: [13, 15], frac: 0.42, kind: "body" , slot: "vital" },
+ { id: "limbGrasp", label: "RINGBRP.Loc.GraspLimb", melee: [16, 18], ranged: [16, 17], frac: 0.35, kind: "grasp" , slot: "upper" },
+ { id: "ridge", label: "RINGBRP.Loc.Ridge", melee: [19, 20], ranged: [18, 20], frac: 0.5, kind: "ridge" , slot: "head" }
]
},
/* A CADENCE is not one body. It is four to six small bodies that together are
@@ -91,11 +91,11 @@ export const LOCATION_TABLES = {
cadence: {
label: "RINGBRP.Species.Cadence",
locations: [
- { id: "bodyA", label: "RINGBRP.Loc.BodyA", melee: [1, 4], ranged: [1, 4], frac: 0.28, kind: "unit" },
- { id: "bodyB", label: "RINGBRP.Loc.BodyB", melee: [5, 8], ranged: [5, 8], frac: 0.28, kind: "unit" },
- { id: "bodyC", label: "RINGBRP.Loc.BodyC", melee: [9, 12], ranged: [9, 12], frac: 0.28, kind: "unit" },
- { id: "bodyD", label: "RINGBRP.Loc.BodyD", melee: [13, 16], ranged: [13, 16], frac: 0.28, kind: "unit" },
- { id: "bodyE", label: "RINGBRP.Loc.BodyE", melee: [17, 20], ranged: [17, 20], frac: 0.28, kind: "unit" }
+ { id: "bodyA", label: "RINGBRP.Loc.BodyA", melee: [1, 4], ranged: [1, 4], frac: 0.28, kind: "unit" , slot: "body" },
+ { id: "bodyB", label: "RINGBRP.Loc.BodyB", melee: [5, 8], ranged: [5, 8], frac: 0.28, kind: "unit" , slot: "vital" },
+ { id: "bodyC", label: "RINGBRP.Loc.BodyC", melee: [9, 12], ranged: [9, 12], frac: 0.28, kind: "unit" , slot: "head" },
+ { id: "bodyD", label: "RINGBRP.Loc.BodyD", melee: [13, 16], ranged: [13, 16], frac: 0.28, kind: "unit" , slot: "lower" },
+ { id: "bodyE", label: "RINGBRP.Loc.BodyE", melee: [17, 20], ranged: [17, 20], frac: 0.28, kind: "unit" , slot: "upper" }
]
}
};
diff --git a/ringbrp.mjs b/ringbrp.mjs
index 6657dab..87e64fc 100644
--- a/ringbrp.mjs
+++ b/ringbrp.mjs
@@ -465,21 +465,25 @@ class RingBRPActor extends Actor {
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);
+ const { damage, moved, rescaled } = 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(", ");
+ const BAND = { hurt: "", disabled: " (disabled)", destroyed: " (destroyed)" };
+ const lines = moved
+ .map(m => `${m.from} → ${m.to} ${m.became}${BAND[m.band] ?? ""}`)
+ .join(", ");
ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this }),
- content: `
${this.name}: body plan changed
`
+ 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.
` : "")
+ + (rescaled > 0
+ ? `
${rescaled} rescaled to keep the same severity — `
+ + `the locations are not the same size on both plans.
` : "")
+ `
`,
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
});
diff --git a/rules.mjs b/rules.mjs
index 67853e0..3372f05 100644
--- a/rules.mjs
+++ b/rules.mjs
@@ -600,7 +600,7 @@ const KIND_FALLBACK = {
body: ["vital", "unit"],
leg: ["brace", "arm", "body", "unit"],
arm: ["grasp", "leg", "brace", "body", "unit"],
- wing: ["arm", "leg", "body", "unit"],
+ wing: ["arm", "grasp", "leg", "body", "unit"],
brace: ["leg", "arm", "body", "unit"],
grasp: ["arm", "leg", "body", "unit"],
ridge: ["head", "vital", "body", "unit"],
@@ -610,10 +610,16 @@ const KIND_FALLBACK = {
/**
* 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.
+ * What is preserved is SEVERITY, not the raw number of points: a location that was
+ * merely hurt stays hurt, a disabled one stays disabled, a destroyed one stays
+ * destroyed. Points alone cannot be preserved because a location's maximum comes from
+ * its `frac` and the plans disagree — an arm is 0.35 of the body, a foreleg 0.42 — so
+ * carrying the number across rather than the meaning silently healed destroyed limbs
+ * one way and destroyed merely-disabled ones the other. 234 such flips across the 20
+ * plan changes, 138 of them creating a destroyed location out of one that was not.
+ *
+ * Returns the new map, the moves it made (each with the band it preserved), and how
+ * many wounds had to be rescaled to keep their meaning.
*/
/**
* Build the update that REPLACES a location-damage map.
@@ -635,49 +641,81 @@ export function remapLocationDamage(damage = {}, fromId = "baseline", toId = "ba
const from = (LOCATION_TABLES[fromId] ?? LOCATION_TABLES.baseline).locations;
const to = (LOCATION_TABLES[toId] ?? LOCATION_TABLES.baseline).locations;
const out = {}, moved = [];
- let lost = 0;
+ let rescaled = 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 };
- }
+ if (!to.length || !wounded.length) return { damage: out, moved, rescaled };
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) => {
+ // Slots carry the side in their name, so a wound that had a side keeps it even when it
+ // has to fall back to a different kind — a left wing becomes a LEFT arm, not whichever
+ // arm happened to be first in the table.
+ const sideOf = slot => (/R$/.test(slot) ? "R" : /L$/.test(slot) ? "L" : "");
+ // A wing is a FORE limb, so when it has to fall back to a leg it should become a
+ // foreleg, not a hind leg. Slots carry height as well as side; prefer a target that
+ // matches both, then side alone, then height alone.
+ const highOf = slot => (/^(upper|wing)/.test(slot) ? "upper" : /^lower/.test(slot) ? "lower" : "");
+ const pick = (pool, side, high) =>
+ (side && high && pool.find(t => sideOf(t.slot) === side && highOf(t.slot) === high))
+ || (side && pool.find(t => sideOf(t.slot) === side))
+ || (high && pool.find(t => highOf(t.slot) === high))
+ || pool[0];
+ const freeSlot = slot => to.find(t => t.slot === slot && !used.has(t.id));
+ const freeOfKind = (kind, side = "", high = "") =>
+ pick(to.filter(t => t.kind === kind && !used.has(t.id)), side, high);
+ const anyOfKind = (kind, side = "", high = "") =>
+ pick(to.filter(t => t.kind === kind), side, high);
+
+ const carry = (src, tgt) => {
+ const taken = Number(damage[src.id]) || 0;
+ const srcMax = locationMaxHp(totalHp, src.frac);
+ const tgtMax = locationMaxHp(totalHp, tgt.frac);
+ const band = taken >= srcMax * 2 ? "destroyed" : taken >= srcMax ? "disabled" : "hurt";
+ const scaled = Math.round((taken / srcMax) * tgtMax);
+ // Pinned to the band rather than to the number, because the band is what the rules
+ // read. An arm is 0.35 of the body and a foreleg is 0.42, so carrying the raw points
+ // across turned a destroyed arm into a merely disabled foreleg and — stacking two
+ // limbs onto one Vesh grasp — a disabled arm into a destroyed one.
+ const value = band === "destroyed" ? tgtMax * 2
+ : band === "disabled" ? Math.min(Math.max(scaled, tgtMax), tgtMax * 2 - 1)
+ : Math.max(0, Math.min(scaled, tgtMax - 1));
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);
+ // Two wounds landing on one location: the worse one stands. Summing them would let a
+ // change of shape destroy a limb that nothing in play had destroyed.
+ out[tgt.id] = Math.max(out[tgt.id] ?? 0, value);
+ if (value !== taken) rescaled++;
+ moved.push({ from: src.id, to: tgt.id, points: taken, became: value, band });
};
+ // Pass 1 — the same anatomical slot. Stated in the tables rather than inferred, so a
+ // right arm becomes a right foreleg and becomes a right arm again coming back. An
+ // earlier version matched on kind and list order instead and inverted left and right
+ // on every crossing, which round-tripped an arm wound onto the opposite hind leg.
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);
+ const tgt = freeSlot(src.slot);
+ if (tgt) carry(src, tgt); else pending.push(src);
}
- // Pass 2 — the fallback chain, still only onto unclaimed locations.
- const stillPending = [];
+ // Pass 2 — same kind, for a slot the destination simply does not have.
+ const noKind = [];
for (const src of pending) {
- const tgt = (KIND_FALLBACK[src.kind] ?? []).map(freeOfKind).find(Boolean);
- if (tgt) assign(src, tgt); else stillPending.push(src);
+ const tgt = freeOfKind(src.kind, sideOf(src.slot), highOf(src.slot));
+ if (tgt) carry(src, tgt); else noKind.push(src);
}
- // Pass 3 — everything is claimed, so stack. A second head wound is still a head wound.
+ // Pass 3 — the fallback chain, still only onto unclaimed locations.
+ const stillPending = [];
+ for (const src of noKind) {
+ const side = sideOf(src.slot), high = highOf(src.slot);
+ const tgt = (KIND_FALLBACK[src.kind] ?? []).map(k => freeOfKind(k, side, high)).find(Boolean);
+ if (tgt) carry(src, tgt); else stillPending.push(src);
+ }
+ // Pass 4 — everything is claimed, so share. 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);
+ const side = sideOf(src.slot), high = highOf(src.slot);
+ carry(src, [src.kind, ...(KIND_FALLBACK[src.kind] ?? [])]
+ .map(k => anyOfKind(k, side, high)).find(Boolean) ?? to[0]);
}
-
- // 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 };
+ return { damage: out, moved, rescaled };
}
/**
diff --git a/system.json b/system.json
index b566da9..33e8de6 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.1",
+ "version": "1.7.2",
"compatibility": {
"minimum": 13,
"verified": "14.364"
diff --git a/tools/check-anatomy.mjs b/tools/check-anatomy.mjs
index b2b8c0e..02a9e4a 100644
--- a/tools/check-anatomy.mjs
+++ b/tools/check-anatomy.mjs
@@ -103,68 +103,97 @@ 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. */
+/* 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;
-let pairs = 0;
+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 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`);
+
+ 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;
}
- }
- 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`);
+ 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`);
+ }
}
}
- // 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`);
- }
+ // 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`);
}
- 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 (new Set(slots).size !== slots.length) {
+ problems.push(`${toId}: duplicate slot(s) ${slots.filter((v, i) => slots.indexOf(v) !== i).join(",")}`);
}
}
}
@@ -177,4 +206,5 @@ 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, ${pairs} plan changes carry every wound`);
+ + `every kind handled and destructible, ${pairs} plan changes carried ${carried} `
+ + `wounds with their severity intact`);
diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs
index cc830bd..e9a3466 100644
--- a/tools/check-rules.mjs
+++ b/tools/check-rules.mjs
@@ -503,29 +503,34 @@ 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.
+ // A change of body plan must not strand a wound, and must not change what it meant.
+ // Slots make the pairing explicit, so right stays right across every crossing.
["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(armR) becomes the RIGHT foreleg",
+ Object.keys(RULES.remapLocationDamage({ armR: 4 }, "baseline", "quadruped", 16).damage)[0], "foreLegR"],
+ ["remapLocationDamage(foreLegR) becomes the RIGHT arm",
+ Object.keys(RULES.remapLocationDamage({ foreLegR: 4 }, "quadruped", "baseline", 16).damage)[0], "armR"],
+ ["remapLocationDamage(wingL) becomes the left arm",
+ Object.keys(RULES.remapLocationDamage({ wingL: 3 }, "winged", "baseline", 16).damage)[0], "armL"],
["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],
+ Object.keys(RULES.remapLocationDamage({ head: 4 }, "baseline", "vesh", 16).damage)[0], "ridge"],
+ // Severity is the invariant, not the point count: an arm is 0.35 of the body and a
+ // foreleg 0.42, so a destroyed arm must arrive destroyed rather than merely disabled.
+ ["remapLocationDamage(destroyed arm) arrives destroyed",
+ RULES.remapLocationDamage({ armR: 12 }, "baseline", "quadruped", 16).damage.foreLegR, 14],
+ ["remapLocationDamage(disabled arm) arrives disabled, not destroyed",
+ RULES.remapLocationDamage({ armR: 6 }, "baseline", "quadruped", 16).damage.foreLegR, 7],
+ ["remapLocationDamage(hurt arm) arrives still merely hurt",
+ RULES.remapLocationDamage({ armR: 5 }, "baseline", "quadruped", 16).damage.foreLegR, 6],
+ ["remapLocationDamage(two disabled limbs sharing one) takes the worse, not the sum",
+ RULES.remapLocationDamage({ armR: 6, armL: 6 }, "baseline", "vesh", 16).damage.limbGrasp, 6],
+ ["remapLocationDamage(unchanged plan) is identity",
+ RULES.remapLocationDamage({ chest: 4 }, "baseline", "baseline", 16).damage.chest, 4],
+ ["remapLocationDamage(rescaled) says how many it had to rescale",
+ RULES.remapLocationDamage({ armR: 6 }, "baseline", "quadruped", 16).rescaled, 1],
// 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.