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.