Body plans keep what a wound MEANT, not just its number (R-94b)

1.7.1 carried wounds across a change of body plan by moving the raw
points. Playing it showed that is the wrong invariant. A location is
disabled at its maximum and destroyed at twice it, those maxima come
from `frac`, and the plans disagree — an arm is 0.35 of the body and a
foreleg 0.42 — so moving the number silently changed the meaning.
Measured across the 20 plan changes: 234 severity flips, 138 of them
turning a location that was merely disabled into a destroyed one. A
destroyed arm arrived as a working-ish foreleg; two disabled arms
stacked onto one Vesh grasping limb and arrived destroyed.

So severity is now the invariant. A hurt location arrives hurt, a
disabled one disabled, a destroyed one destroyed; the points are
rescaled to the destination's own maximum to keep that true, and two
wounds sharing one location take the worse rather than the sum, because
relabelling a creature's anatomy must not destroy a limb that nothing
in play destroyed.

Correspondence is now stated rather than inferred. Every location gains
a plan-independent `slot` (lowerR, upperL, vital, head...), and the
remap matches on slot first. The previous version inferred it from
`kind` plus table order, which inverted left and right on every
crossing: a wounded right arm came back as the opposite hind leg. Where
a destination has no matching slot the fallback prefers the same side
and the same height, so a wing becomes a foreleg rather than a hind leg
and a left wing becomes a LEFT arm. The vesh and the cadence are not
bilateral — one grasping limb, five interchangeable units — so their
slots carry no side, which is a fix to my own first pass: giving them
sided slots was a category error the side guard caught.

check-anatomy now proves all four promises over every ordered pair and
severity band: nothing is stranded, severity is preserved, left never
becomes right and fore never becomes hind between bilateral plans, and
no update deletes a wound it is also setting. 432 carried wounds, 0
flips, down from 234. Five induced failures confirm each assertion
fires; two of my first attempts at those did NOT fire and were sharpened
until they did.

Played through in a live world: a church grim filed as a humanoid with a
destroyed right arm, a disabled left arm and a hurt chest becomes a dog
with a destroyed right foreleg, a disabled left foreleg and hurt
forequarters, and converts back to exactly the wounds it started with.
Its penalties correctly stop being manipulation and start being
movement — a man with two ruined arms drops his weapon, a dog with two
ruined forelegs cannot run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-12 16:42:57 +01:00
co-authored by Claude Opus 5
parent a90c4f3e71
commit 4a80cc1eca
6 changed files with 224 additions and 147 deletions
+36 -36
View File
@@ -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" }
]
}
};
+10 -6
View File
@@ -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} &rarr; ${m.to}`).join(", ");
const BAND = { hurt: "", disabled: " (disabled)", destroyed: " (destroyed)" };
const lines = moved
.map(m => `${m.from} &rarr; ${m.to} ${m.became}${BAND[m.band] ?? ""}`)
.join(", ");
ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this }),
content: `<div class="rb-card"><h3>${this.name}: body plan changed</h3>`
content: `<div class="ringbrp-chat"><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>` : "")
+ (rescaled > 0
? `<p class="rb-note">${rescaled} rescaled to keep the same severity &mdash; `
+ `the locations are not the same size on both plans.</p>` : "")
+ `</div>`,
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
});
+72 -34
View File
@@ -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 };
}
/**
+1 -1
View File
@@ -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"
+82 -52
View File
@@ -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`);
+23 -18
View File
@@ -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.