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:
co-authored by
Claude Opus 5
parent
a90c4f3e71
commit
4a80cc1eca
+36
-36
@@ -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
@@ -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: `<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)} → `
|
||||
+ `${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 — `
|
||||
+ `the locations are not the same size on both plans.</p>` : "")
|
||||
+ `</div>`,
|
||||
whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id)
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user