diff --git a/anatomy.mjs b/anatomy.mjs new file mode 100644 index 0000000..13a3e53 --- /dev/null +++ b/anatomy.mjs @@ -0,0 +1,109 @@ +/** + * Species anatomy: where a creature can be hit, and what it looks like. + * + * This lived inside ringbrp.mjs, where only Foundry could reach it. Node cannot import + * the engine — it touches `game`, `Hooks` and `foundry` at load — so any tool that wants + * to know a Cadence has five bodies and no head had no way to ask. tools/make-portraits + * and tools/mj-queue both need exactly that, for the same reason rules.mjs exists: art + * generated from the creature's own geometry CANNOT contradict its hit-location table, + * because they are the same numbers. + * + * Moved byte for byte. ringbrp.mjs now assigns RINGBRP.locationTables and RINGBRP.bodyArt + * from here, so the engine and the tools read one definition. There is no second copy. + */ + +/* Location fractions are of TOTAL hit points. They were sized against the old + 26-point pool; halving HP left a leg at 4 against a sidearm doing 4-6 through + armour, so 64% of hits crippled the limb they struck. Raised so a sidearm + cripples ~40% of the time and a rifle ~55%. */ +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" } + ] + }, + /* A VESH is bilaterally ASYMMETRIC: one long grasping limb, one short bracing + limb, and no head at all — the senses run along a dorsal ridge. There is no + single killing shot to the skull because there is no skull. The ridge is the + vital, and it is a long target rather than a small one. */ + 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" } + ] + }, + /* A CADENCE is not one body. It is four to six small bodies that together are + one person, and the location roll picks which body you hit. Killing a body + does not kill the person — it reduces them. There is no vital and no head: + every body carries an equal share, and the person degrades as they are + whittled down. This is why shooting a Cadence feels like nothing is working. */ + 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" } + ] + } +}; + +/** + * The figure drawn on the combat tab. One entry per species profile, keyed by the + * same location ids as the table above, so a species that has a location gets a + * shape and one that does not simply has none — no separate list to keep in step. + * + * Deliberately blocky: these are readable at sheet size and honest about the + * anatomy. A Vesh is asymmetric with no head; a Cadence is five separate bodies. + */ +export const BODY_ART = { + baseline: { + viewBox: "0 0 120 196", + parts: { + head: { t: "ellipse", cx: 60, cy: 21, rx: 15, ry: 17 }, + chest: { t: "rect", x: 40, y: 42, w: 40, h: 40, rx: 9 }, + abdomen: { t: "rect", x: 44, y: 84, w: 32, h: 30, rx: 8 }, + armR: { t: "rect", x: 18, y: 45, w: 17, h: 64, rx: 8 }, + armL: { t: "rect", x: 85, y: 45, w: 17, h: 64, rx: 8 }, + legR: { t: "rect", x: 43, y: 117, w: 15, h: 72, rx: 7 }, + legL: { t: "rect", x: 62, y: 117, w: 15, h: 72, rx: 7 } + } + }, + /* One long grasping limb, one short bracing limb, a trunk that leans, and the + sensory ridge running down the back instead of a head. */ + vesh: { + viewBox: "0 0 120 196", + parts: { + ridge: { t: "path", d: "M44 18 L70 14 L76 96 L52 100 Z" }, + upTrunk: { t: "path", d: "M40 34 L70 30 L74 84 L44 88 Z" }, + lowTrunk: { t: "path", d: "M44 90 L74 86 L78 132 L48 136 Z" }, + limbGrasp: { t: "path", d: "M74 36 L96 44 L104 148 L86 150 L82 60 Z" }, + limbBrace: { t: "rect", x: 20, y: 52, w: 16, h: 48, rx: 8 }, + footBrace: { t: "path", d: "M46 136 L78 132 L84 188 L58 190 Z" } + } + }, + /* Five bodies, one person. No vital, no head, every body an equal share. */ + cadence: { + viewBox: "0 0 120 196", + parts: { + bodyA: { t: "ellipse", cx: 34, cy: 52, rx: 20, ry: 24 }, + bodyB: { t: "ellipse", cx: 86, cy: 44, rx: 20, ry: 24 }, + bodyC: { t: "ellipse", cx: 60, cy: 100, rx: 20, ry: 24 }, + bodyD: { t: "ellipse", cx: 28, cy: 148, rx: 20, ry: 24 }, + bodyE: { t: "ellipse", cx: 88, cy: 152, rx: 20, ry: 24 } + } + } +}; diff --git a/ringbrp.mjs b/ringbrp.mjs index 675051c..644c5e5 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -56,6 +56,7 @@ import { panicBandFrom, panicPenaltyFor, panicBreaksAt, panicAfter, panicReliefFor, DEVELOPMENT, developmentMarksOn, developmentImproves, developmentGainFor } from "./rules.mjs"; +import { LOCATION_TABLES, BODY_ART } from "./anatomy.mjs"; export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating }; function rbRenderTemplate(path, data) { @@ -187,101 +188,11 @@ RINGBRP.majorWoundTable = MAJOR_WOUND_TABLE; * Melee and ranged distributions differ: melee finds limbs because * they are between you and the target; ranged finds centre of mass. * ============================================================ */ -/* Location fractions are of TOTAL hit points. They were sized against the old - 26-point pool; halving HP left a leg at 4 against a sidearm doing 4-6 through - armour, so 64% of hits crippled the limb they struck. Raised so a sidearm - cripples ~40% of the time and a rifle ~55%. */ -RINGBRP.locationTables = { - 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" } - ] - }, - /* A VESH is bilaterally ASYMMETRIC: one long grasping limb, one short bracing - limb, and no head at all — the senses run along a dorsal ridge. There is no - single killing shot to the skull because there is no skull. The ridge is the - vital, and it is a long target rather than a small one. */ - 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" } - ] - }, - /* A CADENCE is not one body. It is four to six small bodies that together are - one person, and the location roll picks which body you hit. Killing a body - does not kill the person — it reduces them. There is no vital and no head: - every body carries an equal share, and the person degrades as they are - whittled down. This is why shooting a Cadence feels like nothing is working. */ - 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" } - ] - } -}; - -/** - * The figure drawn on the combat tab. One entry per species profile, keyed by the - * same location ids as the table above, so a species that has a location gets a - * shape and one that does not simply has none — no separate list to keep in step. - * - * Deliberately blocky: these are readable at sheet size and honest about the - * anatomy. A Vesh is asymmetric with no head; a Cadence is five separate bodies. - */ -RINGBRP.bodyArt = { - baseline: { - viewBox: "0 0 120 196", - parts: { - head: { t: "ellipse", cx: 60, cy: 21, rx: 15, ry: 17 }, - chest: { t: "rect", x: 40, y: 42, w: 40, h: 40, rx: 9 }, - abdomen: { t: "rect", x: 44, y: 84, w: 32, h: 30, rx: 8 }, - armR: { t: "rect", x: 18, y: 45, w: 17, h: 64, rx: 8 }, - armL: { t: "rect", x: 85, y: 45, w: 17, h: 64, rx: 8 }, - legR: { t: "rect", x: 43, y: 117, w: 15, h: 72, rx: 7 }, - legL: { t: "rect", x: 62, y: 117, w: 15, h: 72, rx: 7 } - } - }, - /* One long grasping limb, one short bracing limb, a trunk that leans, and the - sensory ridge running down the back instead of a head. */ - vesh: { - viewBox: "0 0 120 196", - parts: { - ridge: { t: "path", d: "M44 18 L70 14 L76 96 L52 100 Z" }, - upTrunk: { t: "path", d: "M40 34 L70 30 L74 84 L44 88 Z" }, - lowTrunk: { t: "path", d: "M44 90 L74 86 L78 132 L48 136 Z" }, - limbGrasp: { t: "path", d: "M74 36 L96 44 L104 148 L86 150 L82 60 Z" }, - limbBrace: { t: "rect", x: 20, y: 52, w: 16, h: 48, rx: 8 }, - footBrace: { t: "path", d: "M46 136 L78 132 L84 188 L58 190 Z" } - } - }, - /* Five bodies, one person. No vital, no head, every body an equal share. */ - cadence: { - viewBox: "0 0 120 196", - parts: { - bodyA: { t: "ellipse", cx: 34, cy: 52, rx: 20, ry: 24 }, - bodyB: { t: "ellipse", cx: 86, cy: 44, rx: 20, ry: 24 }, - bodyC: { t: "ellipse", cx: 60, cy: 100, rx: 20, ry: 24 }, - bodyD: { t: "ellipse", cx: 28, cy: 148, rx: 20, ry: 24 }, - bodyE: { t: "ellipse", cx: 88, cy: 152, rx: 20, ry: 24 } - } - } -}; +/* Location tables and body geometry live in anatomy.mjs so the node-side tools can read + them too — a portrait generated from the creature's own geometry cannot contradict its + hit-location table. The engine reads that one definition rather than a copy of it. */ +RINGBRP.locationTables = LOCATION_TABLES; +RINGBRP.bodyArt = BODY_ART; /** What losing a location does, by anatomical KIND rather than by name, * so a new species inherits sensible consequences for free. */ diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 3c883f1..1a9e765 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -97,10 +97,13 @@ for (const name of constants) { * because the sheet falls back to baseline, so three of four agents were quietly * drawn and hit-located as something the game does not define. */ { - const engineSrc = await readFile(new URL("ringbrp.mjs", root), "utf8"); - const tablesBlock = engineSrc.match(/^RINGBRP\.locationTables = \{([\s\S]*?)^\};/m); - const profiles = tablesBlock - ? [...tablesBlock[1].matchAll(/^ {2}(\w+):\s*\{/gm)].map(x => x[1]) : []; + // Imported rather than scraped out of the engine source. The tables moved to + // anatomy.mjs so node-side tools could read them, and this guard — which matched them + // with a regex against ringbrp.mjs — went blind the moment they did. A guard that + // parses source text fails silently-shaped when the source is refactored; one that + // imports the value cannot. + const { LOCATION_TABLES } = await import("../anatomy.mjs"); + const profiles = Object.keys(LOCATION_TABLES); const tpl = JSON.parse(await readFile(new URL("template.json", root), "utf8")); const found = []; (function walk(o) {