diff --git a/ringbrp.mjs b/ringbrp.mjs index c8ae68e..6657dab 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -24,7 +24,8 @@ import { locationMaxHp, reflexRating, awarenessRating, encumbranceFrom, reactionBaseFrom, reactionLoadFrom, reactionPrintedFrom, woundEffectsFor, BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor, - locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, + locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, remapLocationDamage, + locationDamageReplacement, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, @@ -439,6 +440,53 @@ export function resolveHit({ level, rolled, armour, majorWoundThreshold }) { class RingBRPActor extends Actor { + /** + * Carry wounds across a change of body plan. + * + * locationDamage is keyed by location id and prepareDerivedData reads only the keys + * belonging to the current plan, so switching speciesProfile on a wounded actor left + * every existing wound in the document unreadable — R-93 all over again, except the + * orphan here is a limb rather than a flag. Found on a live Barghest that had been + * shot in the leg and chest as a humanoid: 12 points of damage, exactly its missing + * hit points, stranded under keys no quadruped location answers to. + * + * The remap is a rule and lives in rules.mjs. This only spots the change, applies the + * result to the same update, and says out loud what it moved — silently rearranging a + * GM's wounded creature is not something to do without a receipt. + */ + async _preUpdate(changed, options, user) { + const result = await super._preUpdate(changed, options, user); + if (result === false) return result; + + const to = changed?.system?.speciesProfile; + const from = this.system?.speciesProfile || "baseline"; + if (!to || to === from) return result; + + const current = this.system?.locationDamage ?? {}; + if (!Object.values(current).some(v => (Number(v) || 0) > 0)) return result; + + const { damage, moved, lost } = remapLocationDamage(current, from, to, + Number(this.system?.hp?.max) || 0); + + changed.system.locationDamage = locationDamageReplacement(current, damage); + + if (moved.length) { + const lines = moved.map(m => `${m.from} ${m.points} → ${m.to}`).join(", "); + ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ actor: this }), + content: `

${this.name}: body plan changed

` + + `

${game.i18n.localize(LOCATION_TABLES[from]?.label ?? from)} → ` + + `${game.i18n.localize(LOCATION_TABLES[to]?.label ?? to)}

` + + `

Wounds carried across: ${lines}.

` + + (lost > 0 ? `

${lost} point(s) could not be placed — ` + + `the destination was already at its destroyed limit.

` : "") + + `
`, + whisper: ChatMessage.getWhisperRecipients("GM").map(u => u.id) + }); + } + return result; + } + prepareDerivedData() { super.prepareDerivedData(); const sys = this.system; @@ -6236,7 +6284,8 @@ Hooks.once("init", () => { TIERS, THREATS, SPECIES, npcTarget, npcShouldWithdraw, declareNpcTarget, setReaction, - locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor + locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor, + remapLocationDamage, locationDamageReplacement }; }); diff --git a/rules.mjs b/rules.mjs index a23d25c..67853e0 100644 --- a/rules.mjs +++ b/rules.mjs @@ -573,6 +573,113 @@ export function locationsFor(speciesId = "baseline") { return (LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline).locations; } +/** + * WHERE A WOUND GOES WHEN THE BODY CHANGES SHAPE. + * + * `locationDamage` is keyed by location id, and prepareDerivedData only reads the keys + * belonging to the CURRENT body plan. So changing speciesProfile on a wounded actor + * stranded every wound it had: a Barghest switched from baseline to quadruped kept 5 + * points in `legR` and 7 in `chest`, which no quadruped location is called, and read on + * the sheet as a dog 12 hit points down with eight pristine locations. Stored, never + * read — the same defect this system keeps producing. + * + * Wounds are carried across by KIND, because kind is what the rules act on: a leg is + * what you limp on whichever plan names it, a vital is what kills you. Exact-kind + * matches are claimed first, in table order, so the obvious pairings land before any + * fallback competes for them; only then do the leftovers walk the fallback chain. + * + * That ordering is what makes arms become forelegs. Going baseline -> quadruped the two + * legs take the two hind legs, and the arms — finding no `arm` and no `grasp` — fall to + * `leg` and take the forelegs that are left. Coming back, the forelegs become arms. The + * limb you lost stays the limb you lost. + */ +const KIND_FALLBACK = { + head: ["neck", "ridge", "vital", "body", "unit"], + neck: ["head", "vital", "body", "unit"], + vital: ["body", "ridge", "unit"], + body: ["vital", "unit"], + leg: ["brace", "arm", "body", "unit"], + arm: ["grasp", "leg", "brace", "body", "unit"], + wing: ["arm", "leg", "body", "unit"], + brace: ["leg", "arm", "body", "unit"], + grasp: ["arm", "leg", "body", "unit"], + ridge: ["head", "vital", "body", "unit"], + unit: ["body", "vital", "head", "leg", "arm"] +}; + +/** + * Rebuild a locationDamage map for a new body plan. + * + * Returns the new map, the moves it made, and how much damage could not be placed — + * `lost` is only ever non-zero when a target is already at its destroyed ceiling (2x + * max) or the target plan has no locations at all. The caller reports it rather than + * discarding it quietly, which is the whole point of the exercise. + */ +/** + * Build the update that REPLACES a location-damage map. + * + * Foundry merges object updates, so the outgoing plan's keys must be deleted by name or + * the actor carries both plans' wounds at once (R-93). But a deletion and an assignment + * of the SAME key in one update do not both apply — the deletion wins — and the plans + * that share ids are exactly where that bites: quadruped and winged share every location + * but the wings, so a switch between them emitted `-=hindLegR` alongside `hindLegR: 5` + * and silently threw the wound away. Delete only what the new map does not set. + */ +export function locationDamageReplacement(current = {}, next = {}) { + const upd = { ...next }; + for (const id of Object.keys(current)) if (!(id in next)) upd[`-=${id}`] = null; + return upd; +} + +export function remapLocationDamage(damage = {}, fromId = "baseline", toId = "baseline", totalHp = 0) { + const from = (LOCATION_TABLES[fromId] ?? LOCATION_TABLES.baseline).locations; + const to = (LOCATION_TABLES[toId] ?? LOCATION_TABLES.baseline).locations; + const out = {}, moved = []; + let lost = 0; + + const wounded = from.filter(l => (Number(damage[l.id]) || 0) > 0); + if (!to.length) { + for (const l of wounded) lost += Number(damage[l.id]) || 0; + return { damage: out, moved, lost }; + } + + const used = new Set(); + const freeOfKind = kind => to.find(t => t.kind === kind && !used.has(t.id)); + const anyOfKind = kind => to.find(t => t.kind === kind); + const assign = (src, tgt) => { + used.add(tgt.id); + moved.push({ from: src.id, to: tgt.id, points: Number(damage[src.id]) || 0 }); + out[tgt.id] = (out[tgt.id] || 0) + (Number(damage[src.id]) || 0); + }; + + const pending = []; + // Pass 1 — exact kind, unclaimed. The obvious pairings, before fallbacks compete. + for (const src of wounded) { + const tgt = freeOfKind(src.kind); + if (tgt) assign(src, tgt); else pending.push(src); + } + // Pass 2 — the fallback chain, still only onto unclaimed locations. + const stillPending = []; + for (const src of pending) { + const tgt = (KIND_FALLBACK[src.kind] ?? []).map(freeOfKind).find(Boolean); + if (tgt) assign(src, tgt); else stillPending.push(src); + } + // Pass 3 — everything is claimed, so stack. A second head wound is still a head wound. + for (const src of stillPending) { + const tgt = [src.kind, ...(KIND_FALLBACK[src.kind] ?? [])].map(anyOfKind).find(Boolean) + ?? to[0]; + assign(src, tgt); + } + + // Nothing may arrive already worse than destroyed. + for (const t of to) { + if (!(t.id in out)) continue; + const ceiling = locationMaxHp(totalHp, t.frac) * 2; + if (out[t.id] > ceiling) { lost += out[t.id] - ceiling; out[t.id] = ceiling; } + } + return { damage: out, moved, lost }; +} + /** * How much a battered body costs a roll of one category. * diff --git a/system.json b/system.json index 2df0bc2..b566da9 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "ringbrp", "title": "The Custodians", "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.", - "version": "1.7.0", + "version": "1.7.1", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/tools/check-anatomy.mjs b/tools/check-anatomy.mjs index e43e525..b2b8c0e 100644 --- a/tools/check-anatomy.mjs +++ b/tools/check-anatomy.mjs @@ -17,13 +17,19 @@ * 3. Every `kind` used by any table is one locationEffectsFor actually handles, * has effect text to print, and has a declared destruction outcome. A kind * nobody handles falls through to "body" and quietly does nothing. + * 4. A wound survives a change of body plan. locationDamage is keyed by location + * id, and only the current plan's keys are ever read, so every plan-to-plan + * move must land every wound on a location the destination actually has. + * Caught live: a Barghest switched to quadruped kept 5 points in `legR` and 7 + * in `chest` — 12 hit points of damage under keys nothing answers to. */ import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { LOCATION_TABLES, BODY_ART } from "../anatomy.mjs"; -import { locationEffectsFor, destructionOutcomeFor } from "../rules.mjs"; +import { locationEffectsFor, destructionOutcomeFor, remapLocationDamage, + locationDamageReplacement, locationMaxHp } from "../rules.mjs"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const problems = []; @@ -97,6 +103,72 @@ for (const [id, table] of Object.entries(LOCATION_TABLES)) { } } +/* 4. no wound is stranded by a change of body plan -------------------------- * + * Every ordered pair, with one point in every location of the source, so each plan's + * every kind is forced to find a home in every other plan. */ +const HP = 16; +let pairs = 0; +for (const [fromId, fromT] of Object.entries(LOCATION_TABLES)) { + for (const [toId, toT] of Object.entries(LOCATION_TABLES)) { + if (fromId === toId) continue; + pairs++; + const src = Object.fromEntries(fromT.locations.map(l => [l.id, 1])); + const inSum = fromT.locations.length; + const { damage, moved, lost } = remapLocationDamage(src, fromId, toId, HP); + + const valid = new Set(toT.locations.map(l => l.id)); + for (const id of Object.keys(damage)) { + if (!valid.has(id)) { + problems.push(`${fromId}->${toId}: wound placed on "${id}", which is not a ${toId} location ` + + `— it would be stranded exactly like the Barghest's`); + } + } + if (moved.length !== fromT.locations.length) { + problems.push(`${fromId}->${toId}: ${fromT.locations.length} wounds in, ${moved.length} moved ` + + `— ${fromT.locations.length - moved.length} vanished without being reported`); + } + const outSum = Object.values(damage).reduce((a, b) => a + b, 0); + if (outSum + lost !== inSum) { + problems.push(`${fromId}->${toId}: ${inSum} points in, ${outSum} placed + ${lost} reported lost ` + + `— the arithmetic does not close`); + } + if (lost !== 0) { + problems.push(`${fromId}->${toId}: lost ${lost} point(s) placing one point per location ` + + `— nothing should be unplaceable at this size`); + } + // A wound may never arrive already destroyed. + for (const [id, v] of Object.entries(damage)) { + const t = toT.locations.find(l => l.id === id); + if (t && v > locationMaxHp(HP, t.frac) * 2) { + problems.push(`${fromId}->${toId}: ${id} arrives at ${v}, past its destroyed ceiling`); + } + } + + // The update that carries this must not delete a wound it is also setting. Foundry + // applies the deletion, so an overlapping id loses its damage entirely. + const upd = locationDamageReplacement(src, damage); + for (const id of Object.keys(damage)) { + if (`-=${id}` in upd) { + problems.push(`${fromId}->${toId}: the update both sets and deletes "${id}" ` + + `— Foundry applies the deletion and the wound is thrown away`); + } + } + const kept = Object.fromEntries(Object.entries(upd).filter(([k]) => !k.startsWith("-="))); + if (Object.values(kept).reduce((x, y) => x + y, 0) !== outSum) { + problems.push(`${fromId}->${toId}: the update carries ` + + `${Object.values(kept).reduce((x, y) => x + y, 0)} points, the remap placed ${outSum}`); + } + + // And the way back must be just as total. + const back = remapLocationDamage(damage, toId, fromId, HP); + const backSum = Object.values(back.damage).reduce((a, b) => a + b, 0); + if (backSum + back.lost + lost !== inSum) { + problems.push(`${fromId}->${toId}->${fromId}: round trip leaks ` + + `(${inSum} -> ${outSum} -> ${backSum}, ${lost + back.lost} reported lost)`); + } + } +} + if (problems.length) { console.error("check-anatomy: FAILED"); for (const p of [...new Set(problems)]) console.error(" " + p); @@ -105,4 +177,4 @@ if (problems.length) { console.log(`check-anatomy: OK — ${Object.keys(LOCATION_TABLES).length} body plans, ` + `${locCount} locations, d20 complete in both modes, every location drawn, ` - + `every kind handled and destructible`); + + `every kind handled and destructible, ${pairs} plan changes carry every wound`); diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index bef24f1..cc830bd 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -503,6 +503,38 @@ const checks = [ ["a winged melee 17 is a wing too", RULES.locationFor(17, "winged", "melee").kind, "wing"], ["a destroyed neck is death", RULES.destructionOutcomeFor("neck", true), "dead"], ["a destroyed wing is not", RULES.destructionOutcomeFor("wing", true), "none"], + // A change of body plan must not strand a wound. The exact-kind pass claims the + // obvious pairings first, so the arms are still free to become the forelegs. + ["remapLocationDamage(legR) becomes a hind leg", + RULES.remapLocationDamage({ legR: 5 }, "baseline", "quadruped", 16).damage.hindLegR, 5], + ["remapLocationDamage(chest) becomes the forequarters", + RULES.remapLocationDamage({ chest: 7 }, "baseline", "quadruped", 16).damage.foreQtr, 7], + ["remapLocationDamage(armR) becomes a foreleg", + RULES.remapLocationDamage({ legR: 1, legL: 1, armR: 4 }, "baseline", "quadruped", 16).damage.foreLegR, 4], + ["remapLocationDamage(foreLegR) becomes an arm", + RULES.remapLocationDamage({ hindLegR: 1, hindLegL: 1, foreLegR: 4 }, "quadruped", "baseline", 16).damage.armR, 4], + ["remapLocationDamage(wingR) becomes an arm", + RULES.remapLocationDamage({ wingR: 3 }, "winged", "baseline", 16).damage.armR, 3], + ["remapLocationDamage(head) becomes the vesh ridge", + RULES.remapLocationDamage({ head: 4 }, "baseline", "vesh", 16).damage.ridge, 4], + ["remapLocationDamage keeps the total", + Object.values(RULES.remapLocationDamage({ legR: 2, chest: 5, head: 3 }, "baseline", "cadence", 16).damage) + .reduce((a, b) => a + b, 0), 10], + ["remapLocationDamage clamps at the destroyed ceiling", + RULES.remapLocationDamage({ head: 99 }, "baseline", "quadruped", 16).damage.head, 14], + ["remapLocationDamage reports what it could not place", + RULES.remapLocationDamage({ head: 99 }, "baseline", "quadruped", 16).lost, 85], + ["remapLocationDamage on an unchanged plan is identity", + RULES.remapLocationDamage({ chest: 4 }, "baseline", "baseline", 16).damage.chest, 4], + // A deletion and an assignment of the same key do not both apply: the deletion wins. + // quadruped and winged share every location but the wings, so this is where a wound + // was silently thrown away instead of carried. + ["locationDamageReplacement(overlapping id) keeps the value", + RULES.locationDamageReplacement({ hindLegR: 5 }, { hindLegR: 5 }).hindLegR, 5], + ["locationDamageReplacement(overlapping id) does not also delete it", + "-=hindLegR" in RULES.locationDamageReplacement({ hindLegR: 5 }, { hindLegR: 5 }), false], + ["locationDamageReplacement(dropped id) deletes it", + RULES.locationDamageReplacement({ legR: 5 }, { hindLegR: 5 })["-=legR"], null], ["one lost wing grounds it", RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true], ["an opened body cavity bleeds, as its text says",