Hit locations reach ordinary combat, and creatures get a body

THE DEFECT. locationFor had ONE caller in the engine, hitLocationRoll, and
hitLocationRoll was called from fall, burn, detonate and burstAttack. Falling,
fire, explosions and automatic fire. Nothing else. rollWeaponDamage rolled dice,
printed a number and returned it, so the chat Damage button, the character
sheet's damage button and the item sheet's all stopped there.

A grenade knew which arm it took off. A sword did not.

Every individual piece was already correct - the tables, per-location armour and
coverage, disable and destroy thresholds, Major Wound, dying, death, the figure
on the sheet. They simply were not joined up, which is why this reads as a wiring
change rather than a new subsystem.

- rollWeaponDamage now carries through to hitLocationRoll: it rolls the location,
  applies that location's armour, applies the wound, and handles the rest through
  the machinery that already owned it. Nothing here re-implements any of that
- locationModeFor picks the melee or ranged column from the weapon's class. The
  two columns have always differed and nothing was choosing between them
- with no target it does exactly what it did before and SAYS SO on a card, rather
  than failing quietly
- burstAttack opts out with locate:false - it rolls its own location per round,
  and letting both fire would have applied every round of a burst twice

BODY PLANS. Two new tables: quadruped (four legs, fore/hindquarters, neck, a head
that is hard to reach) and winged (that, plus wings). Forequarters are the vital.
DISABLE EITHER WING AND IT CANNOT FLY, which is the fight a party can win.
Barghest, Kelpie and Church grim were all species "baseline" - a black dog, a
horse and a grave-hound, each hit-located with two arms and a chest. Fixed. Added
"The supporter", a winged heraldic beast, so the new table is exercised by real
content instead of existing unused.

Body plan is a NEW FIELD, not a species. A bear is not a playable people with
characteristic dice and talents, so creature specs take bodyPlan and the playable
species list is untouched.

THE NPC SHEET had no hit locations at all - 102 lines against the character
sheet's 627 - while every NPC in the game had them derived and wired to
consequences. It now carries the body-plan selector, the figure, the per-location
table, the condition and a GROUNDED flag. The sheet class already extended the
character sheet, so it already HAD the context; the template never used it.

FOUND IN PASSING, by the new guard: kind "body" - the abdomen, the trunk, the
hindquarters - did nothing whatever. Its printed effect has always promised "-30%
to all Physical actions and bleeding 1 hit point per round until First Aid" and
the code delivered none of it, on every body plan including the humanoid default.
It now bleeds 1, or 2 destroyed, exactly as bodyX says.

NEW GUARD: check-anatomy. Every body plan must cover 1-20 exactly once in BOTH
modes, every location must be drawn and every drawn shape must be a location, and
every kind must be one locationEffectsFor acts on, has effect text for, and has a
destruction outcome. Verified it fails on a d20 gap, a shapeless limb and an
unhandled kind before wiring it in. Ninth guard.

VERIFIED IN A RUNNING FOUNDRY, not just unit-tested: melee bar reads the Melee
column and the rifle reads Ranged on the same creature; forequarters graded as a
vital hit; a maxed wing set flightLost and the sheet said GROUNDED; the real chat
Damage button rolled 1D20 15, found the right foreleg, applied 12 through armour,
disabled the leg and took the dog from 16 to 4; and with nothing targeted nothing
was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-12 15:46:11 +01:00
co-authored by Claude Opus 5
parent 748e061b56
commit ddc4f99ff4
18 changed files with 538 additions and 28 deletions
+7 -7
View File
@@ -66,7 +66,7 @@ changing underneath a running server.
| **Trades** | 10 ordinary jobs the department recruits from, plus induction | | **Trades** | 10 ordinary jobs the department recruits from, plus induction |
| **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles | | **Catalogue** | 236 items — 123 gear, 62 weapons, 32 armour, 19 vehicles |
| **Skills / talents** | 62 skills, 19 talents | | **Skills / talents** | 62 skills, 19 talents |
| **Bestiary** | 46 statblocks across folklore, horror and the far side | | **Bestiary** | 47 statblocks across folklore, horror and the far side |
| **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything | | **Eras** | modern, antique, future, anomalous — the agency is old and never abolishes anything |
| **Icons** | 292, all generated from `tools/icons.mjs`; no external art | | **Icons** | 292, all generated from `tools/icons.mjs`; no external art |
| **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure | | **Starter scenario** | **BLACK PLATFORM**, a six-player action starter, importable as a single Adventure |
@@ -181,13 +181,13 @@ The build runs all eight guards before it writes anything:
<!-- guards:start --> <!-- guards:start -->
``` ```
check-rules: OK — 103 rules, 33 files scanned, 3 aliases + 46 constant sets checked, 346 formulas verified check-rules: OK — 103 rules, 34 files scanned, 3 aliases + 46 constant sets checked, 358 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 995 keys, no leaf/branch collisions check-lang: OK — en.json, 1015 keys, no leaf/branch collisions
check-templates: OK — 19 templates compile check-templates: OK — 19 templates compile
check-behaviour: OK — 67 behavioural tests check-behaviour: OK — 75 behavioural tests
check-scenarios: OK — 9 scenario files, 62 mechanic tags, every skill named resolves against 62 catalogue entries check-scenarios: OK — 10 scenario files, 78 mechanic tags, every skill named resolves against 62 catalogue entries
check-creatures: OK — 101 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys check-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys
check-lethality: OK — 46 creatures, none drifted more than 6 points of wipe rate against the frozen party check-lethality: OK — 47 creatures, none drifted more than 6 points of wipe rate against the frozen party, 1 new
``` ```
<!-- guards:end --> <!-- guards:end -->
+72
View File
@@ -29,6 +29,45 @@ export const LOCATION_TABLES = {
{ id: "head", label: "RINGBRP.Loc.Head", melee: [19, 20], ranged: [20, 20], frac: 0.4, kind: "head" } { id: "head", label: "RINGBRP.Loc.Head", melee: [19, 20], ranged: [20, 20], frac: 0.4, kind: "head" }
] ]
}, },
/* A QUADRUPED — a bear, a horse, a hound, a big cat. Four legs, a barrel divided
into forequarters (heart and lungs, the killing shot) and hindquarters, a neck
worth aiming at, and a head that is HARDER to hit than a man's because it is
carried low and swung. The standard BRP animal table, split by attack type for
the same reason baseline is: a man with a spear is down among the legs, a man
with a rifle is looking at a broadside. */
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" }
]
},
/* A WINGED QUADRUPED — a dragon, a wyvern, a griffon. The quadruped, plus the two
things that make it a different fight: wings you can shoot down. On the ground
the wings are folded high and hard to reach; in the air they are the broadest
target on the animal, which is why the ranged column gives them six faces and
the melee column two. DISABLE ONE AND IT CANNOT FLY — see locationEffectsFor. */
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" }
]
},
/* A VESH is bilaterally ASYMMETRIC: one long grasping limb, one short bracing /* 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 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 single killing shot to the skull because there is no skull. The ridge is the
@@ -82,6 +121,39 @@ export const BODY_ART = {
legL: { t: "rect", x: 62, y: 117, w: 15, h: 72, rx: 7 } legL: { t: "rect", x: 62, y: 117, w: 15, h: 72, rx: 7 }
} }
}, },
/* Side-on, because a quadruped seen from the front tells you nothing about which
end you hit. Landscape viewBox — the partial reads it from here, so a body plan
that is wider than it is tall simply draws that way. */
quadruped: {
viewBox: "0 0 208 132",
parts: {
hindQtr: { t: "rect", x: 34, y: 42, w: 58, h: 44, rx: 14 },
foreQtr: { t: "rect", x: 88, y: 38, w: 62, h: 50, rx: 14 },
neck: { t: "path", d: "M142 44 L176 24 L188 40 L154 62 Z" },
head: { t: "ellipse", cx: 186, cy: 28, rx: 19, ry: 14 },
hindLegR: { t: "rect", x: 40, y: 84, w: 13, h: 44, rx: 6 },
hindLegL: { t: "rect", x: 60, y: 84, w: 13, h: 44, rx: 6 },
foreLegR: { t: "rect", x: 104, y: 86, w: 13, h: 42, rx: 6 },
foreLegL: { t: "rect", x: 124, y: 86, w: 13, h: 42, rx: 6 }
}
},
/* The quadruped with both wings drawn spread rather than folded, because the
table's ranged column assumes a thing in the air. */
winged: {
viewBox: "0 0 208 132",
parts: {
wingR: { t: "path", d: "M96 40 L44 6 L18 26 L74 52 Z" },
wingL: { t: "path", d: "M100 44 L58 92 L82 116 L114 62 Z" },
hindQtr: { t: "rect", x: 34, y: 44, w: 56, h: 40, rx: 13 },
foreQtr: { t: "rect", x: 86, y: 40, w: 60, h: 46, rx: 13 },
neck: { t: "path", d: "M138 46 L172 26 L184 42 L150 64 Z" },
head: { t: "ellipse", cx: 184, cy: 30, rx: 18, ry: 13 },
hindLegR: { t: "rect", x: 42, y: 82, w: 12, h: 40, rx: 6 },
hindLegL: { t: "rect", x: 60, y: 82, w: 12, h: 40, rx: 6 },
foreLegR: { t: "rect", x: 102, y: 84, w: 12, h: 38, rx: 6 },
foreLegL: { t: "rect", x: 120, y: 84, w: 12, h: 38, rx: 6 }
}
},
/* One long grasping limb, one short bracing limb, a trunk that leans, and the /* One long grasping limb, one short bracing limb, a trunk that leans, and the
sensory ridge running down the back instead of a head. */ sensory ridge running down the back instead of a head. */
vesh: { vesh: {
+23 -2
View File
@@ -3,7 +3,7 @@
*Generated by `tools/bestiary.mjs`. Do not edit: every number is derived from the *Generated by `tools/bestiary.mjs`. Do not edit: every number is derived from the
statblock or read from the lethality baseline, so this cannot disagree with the game.* statblock or read from the lethality baseline, so this cannot disagree with the game.*
46 creatures. 10 of them are not human-shaped. 47 creatures. 10 of them are not human-shaped.
## At a glance ## At a glance
@@ -57,6 +57,7 @@ Sorted by how much of a four-agent party each one puts on the floor, alone. **So
| C | **The committee** | It went into session in 1953 and has not risen | 12 | 2 | 15 | Punch / kick 30% | 0.00 | | C | **The committee** | It went into session in 1953 and has not risen | 12 | 2 | 15 | Punch / kick 30% | 0.00 |
| | **The minutes** | A faithful record of a meeting that has not been held | 4 | 0 | 7 | Punch / kick 10% | 0.00 | | | **The minutes** | A faithful record of a meeting that has not been held | 4 | 0 | 7 | Punch / kick 10% | 0.00 |
| | **The switchboard** | It is still connecting calls and some of them are answered | 14 | 4 | 17 | Punch / kick 25% | 0.00 | | | **The switchboard** | It is still connecting calls and some of them are answered | 14 | 4 | 17 | Punch / kick 25% | 0.00 |
| | **The supporter** | The winged beast off a borough coat of arms, come down off it | 24 | 6 | 22 | Grasping limb 75% | — |
**V** = Vesh, **C** = Cadence. Everything else is baseline-human anatomy, including **V** = Vesh, **C** = Cadence. Everything else is baseline-human anatomy, including
several things that are plainly not human-shaped — see the note at the foot. several things that are plainly not human-shaped — see the note at the foot.
@@ -99,6 +100,8 @@ Older than the department and largely uninterested in it. Most of these are arra
*The black dog on the road out of the village* *The black dog on the road out of the village*
> **Four-legged** — four legs, no arms. The forequarters are the vital and take the most hits at range; the neck is worth aiming at and the head is hard to reach (1 face in melee).
| STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations | | STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| 18 | 16 | 15 | 5 | 16 | 17 | 3 | 16 | 8 | 7 | | 18 | 16 | 15 | 5 | 16 | 17 | 3 | 16 | 8 | 7 |
@@ -141,6 +144,8 @@ Older than the department and largely uninterested in it. Most of these are arra
*What was buried at the north door to keep the ground* *What was buried at the north door to keep the ground*
> **Four-legged** — four legs, no arms. The forequarters are the vital and take the most hits at range; the neck is worth aiming at and the head is hard to reach (1 face in melee).
| STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations | | STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| 16 | 20 | 14 | 7 | 19 | 11 | 5 | 17 | 9 | 7 | | 16 | 20 | 14 | 7 | 19 | 11 | 5 | 17 | 9 | 7 |
@@ -155,6 +160,8 @@ Older than the department and largely uninterested in it. Most of these are arra
*The horse standing at the water that is not a horse* *The horse standing at the water that is not a horse*
> **Four-legged** — four legs, no arms. The forequarters are the vital and take the most hits at range; the neck is worth aiming at and the head is hard to reach (1 face in melee).
| STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations | | STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| 21 | 15 | 18 | 9 | 18 | 13 | 2 | 17 | 9 | 7 | | 21 | 15 | 18 | 9 | 18 | 13 | 2 | 17 | 9 | 7 |
@@ -251,6 +258,20 @@ Older than the department and largely uninterested in it. Most of these are arra
*Measured alone against the frozen party: 0.00 of 4 down, 0% wiped, 2 rounds.* *Measured alone against the frozen party: 0.00 of 4 down, 0% wiped, 2 rounds.*
### The supporter
*The winged beast off a borough coat of arms, come down off it*
> **Four-legged, winged** — as the four-legged, plus wings. **Disable either wing and it cannot fly**, which is the fight the players can win. Wings are 6 faces of 20 at range and 2 in melee.
| STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations |
|---|---|---|---|---|---|---|---|---|---|
| 26 | 20 | 24 | 8 | 17 | 12 | 6 | 22 | 11 | 7 |
**Attacks with** Grasping limb at 75%, 1d6+1 · **dodges** 35%
**ARGENT AND GULES.** While it stands on ground the borough holds, it takes half damage from anything the borough owns — which includes the department's vehicles and any weapon signed out of Stores. WING IT.
--- ---
## Horror ## Horror
@@ -760,7 +781,7 @@ as a floor.
## A known gap ## A known gap
21 creatures fight with nothing but their own bodies and still carry 22 creatures fight with nothing but their own bodies and still carry
baseline-human anatomy. Some of those are people and that is fine. Several plainly are baseline-human anatomy. Some of those are people and that is fine. Several plainly are
not — a black dog, a case file that eats the cases filed next to it, a machine that has not — a black dog, a case file that eats the cases filed next to it, a machine that has
decided it is the site — and all of them are hit-located as people, so you can shoot the decided it is the site — and all of them are hit-located as people, so you can shoot the
+21 -1
View File
@@ -464,6 +464,7 @@
"RINGBRP.Loc.Disabled": "disabled", "RINGBRP.Loc.Disabled": "disabled",
"RINGBRP.Loc.Destroyed": "destroyed", "RINGBRP.Loc.Destroyed": "destroyed",
"RINGBRP.Loc.Header": "Hit location", "RINGBRP.Loc.Header": "Hit location",
"RINGBRP.Loc.NoTarget": "No target — damage rolled, but nothing was hit. Target a token and roll damage again to place the blow.",
"RINGBRP.Loc.RollHint": "Roll 1D20 for a hit location on this anatomy", "RINGBRP.Loc.RollHint": "Roll 1D20 for a hit location on this anatomy",
"RINGBRP.Tab.Anatomy": "Anatomy", "RINGBRP.Tab.Anatomy": "Anatomy",
"RINGBRP.Sheet.LocationNote": "A blow meeting a location's maximum disables it; twice that destroys it. Damage also comes off general hit points, so the two readings always agree.", "RINGBRP.Sheet.LocationNote": "A blow meeting a location's maximum disables it; twice that destroys it. Damage also comes off general hit points, so the two readings always agree.",
@@ -895,6 +896,7 @@
"RINGBRP.Coverage.torsoArms": "torso and arms", "RINGBRP.Coverage.torsoArms": "torso and arms",
"RINGBRP.Coverage.torsoLimbs": "torso, arms and legs", "RINGBRP.Coverage.torsoLimbs": "torso, arms and legs",
"RINGBRP.Coverage.head": "head", "RINGBRP.Coverage.head": "head",
"RINGBRP.Coverage.barding": "Barding (barrel, neck and legs)",
"RINGBRP.Sheet.Unarmoured": "bare", "RINGBRP.Sheet.Unarmoured": "bare",
"RINGBRP.Sheet.ByLocation": "depending where you are hit", "RINGBRP.Sheet.ByLocation": "depending where you are hit",
"RINGBRP.Item.Coverage": "Covers", "RINGBRP.Item.Coverage": "Covers",
@@ -993,5 +995,23 @@
"RINGBRP.Dev.GMOnly": "Only the GM can open the development phase.", "RINGBRP.Dev.GMOnly": "Only the GM can open the development phase.",
"RINGBRP.Dev.Button": "Development", "RINGBRP.Dev.Button": "Development",
"RINGBRP.Dev.NotOpen": "The GM has not opened the development phase.", "RINGBRP.Dev.NotOpen": "The GM has not opened the development phase.",
"RINGBRP.Dev.Tooltip": "Roll to improve the skills that failed you this case. A skill improves if the roll is ANOTHER failure." "RINGBRP.Dev.Tooltip": "Roll to improve the skills that failed you this case. A skill improves if the roll is ANOTHER failure.",
"RINGBRP.Species.Quadruped": "Four-legged",
"RINGBRP.Species.Winged": "Four-legged, winged",
"RINGBRP.Loc.HindLegR": "Right hind leg",
"RINGBRP.Loc.HindLegL": "Left hind leg",
"RINGBRP.Loc.ForeLegR": "Right foreleg",
"RINGBRP.Loc.ForeLegL": "Left foreleg",
"RINGBRP.Loc.HindQtr": "Hindquarters",
"RINGBRP.Loc.ForeQtr": "Forequarters",
"RINGBRP.Loc.Neck": "Neck",
"RINGBRP.Loc.WingR": "Right wing",
"RINGBRP.Loc.WingL": "Left wing",
"RINGBRP.LocEffect.neck": "Down, and staying down. Nothing holds its head up.",
"RINGBRP.LocEffect.neckX": "Severed. This is death for anything that carries a head.",
"RINGBRP.LocEffect.wing": "Grounded. It cannot fly on this, and it comes down if it was flying.",
"RINGBRP.LocEffect.wingX": "Torn away. It will never fly again.",
"RINGBRP.Sheet.BodyPlan": "Body plan",
"RINGBRP.Sheet.Grounded": "GROUNDED — a wing is gone",
"RINGBRP.Sheet.Condition": "State"
} }
+1 -1
View File
@@ -10,7 +10,7 @@
"bestiary": "node tools/bestiary.mjs", "bestiary": "node tools/bestiary.mjs",
"mj": "node tools/mj-queue.mjs", "mj": "node tools/mj-queue.mjs",
"simulate": "node tools/simulate.mjs", "simulate": "node tools/simulate.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-creatures.mjs && bun tools/check-lethality.mjs", "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs",
"test": "bun run check", "test": "bun run check",
"readme": "bun tools/update-readme.mjs" "readme": "bun tools/update-readme.mjs"
}, },
Binary file not shown.
+67 -5
View File
@@ -207,7 +207,9 @@ RINGBRP.locationEffects = {
unit: { disabled: "RINGBRP.LocEffect.unit", destroyed: "RINGBRP.LocEffect.unitX" }, unit: { disabled: "RINGBRP.LocEffect.unit", destroyed: "RINGBRP.LocEffect.unitX" },
body: { disabled: "RINGBRP.LocEffect.body", destroyed: "RINGBRP.LocEffect.bodyX" }, body: { disabled: "RINGBRP.LocEffect.body", destroyed: "RINGBRP.LocEffect.bodyX" },
vital: { disabled: "RINGBRP.LocEffect.vital", destroyed: "RINGBRP.LocEffect.vitalX" }, vital: { disabled: "RINGBRP.LocEffect.vital", destroyed: "RINGBRP.LocEffect.vitalX" },
head: { disabled: "RINGBRP.LocEffect.head", destroyed: "RINGBRP.LocEffect.headX" } head: { disabled: "RINGBRP.LocEffect.head", destroyed: "RINGBRP.LocEffect.headX" },
neck: { disabled: "RINGBRP.LocEffect.neck", destroyed: "RINGBRP.LocEffect.neckX" },
wing: { disabled: "RINGBRP.LocEffect.wing", destroyed: "RINGBRP.LocEffect.wingX" }
}; };
// Not a copy — rules.mjs owns the modes; this is the keyed view the item sheet wants, // Not a copy — rules.mjs owns the modes; this is the keyed view the item sheet wants,
@@ -597,13 +599,16 @@ class RingBRPActor extends Actor {
sys.wounded = { sys.wounded = {
legs: loc.legs, legsDestroyed: loc.legsDestroyed, arms: loc.arms, legs: loc.legs, legsDestroyed: loc.legsDestroyed, arms: loc.arms,
vital: loc.vital, unitsLost: loc.unitsLost, vital: loc.vital, unitsLost: loc.unitsLost,
wings: loc.wings, flightLost: loc.flightLost,
movFactor: loc.movFactor * mw.movFactor, movFactor: loc.movFactor * mw.movFactor,
manipulation: loc.manipulation + mw.manipulation, manipulation: loc.manipulation + mw.manipulation,
physical: loc.physical + mw.physical, physical: loc.physical + mw.physical,
perception: loc.perception + mw.perception, perception: loc.perception + mw.perception,
communication: mw.communication, communication: mw.communication,
all: loc.all + mw.all, all: loc.all + mw.all,
bleed: mw.bleed, // Bleeding from an opened body cavity and bleeding from a Major Wound are
// two holes in the same person, so they add.
bleed: loc.bleed + mw.bleed,
defenceOnly: mw.defenceOnly, defenceOnly: mw.defenceOnly,
unconscious: loc.unconscious || mw.unconscious, unconscious: loc.unconscious || mw.unconscious,
lostAction: mw.lostAction, lostAction: mw.lostAction,
@@ -2496,7 +2501,10 @@ export async function burstAttack(actor, weapon, target, { size = "short",
for (let i = 0; i < hits; i++) { for (let i = 0; i < hits; i++) {
// A critical burst puts its FIRST round through armour and the rest normally. // A critical burst puts its FIRST round through armour and the rest normally.
const level = (res.level === "critical" && i === 0) ? "critical" : "success"; const level = (res.level === "critical" && i === 0) ? "critical" : "success";
const dmg = await rollWeaponDamage({ actor, weapon, level }); // locate:false — this loop does its own location roll per round below, and
// rollWeaponDamage now locates by default. Letting both fire would apply every
// round of the burst twice.
const dmg = await rollWeaponDamage({ actor, weapon, level, locate: false });
const hl = await hitLocationRoll({ target, damage: dmg, mode: "ranged", level }); const hl = await hitLocationRoll({ target, damage: dmg, mode: "ranged", level });
if (hl) lines.push(`${dmg} → ${game.i18n.localize(hl.location.label)}`); if (hl) lines.push(`${dmg} → ${game.i18n.localize(hl.location.label)}`);
// ONE Major Wound per burst, however many rounds connected. A burst is a single // ONE Major Wound per burst, however many rounds connected. A burst is a single
@@ -5807,8 +5815,44 @@ async function promptAttackOptions({ weapon, actor }) {
* when the hit is resolved, so what changes here is the weapon dice and the note * when the hit is resolved, so what changes here is the weapon dice and the note
* the card carries. * the card carries.
*/ */
/**
* Which column of the location table a weapon reads.
*
* The tables carry separate melee and ranged spreads — a man with a spear is down
* among the legs, a man with a rifle is looking at a broadside — and nothing was
* choosing between them for an ordinary attack, because an ordinary attack never
* rolled a location at all.
*/
export function locationModeFor(weapon) {
return (weapon?.system?.weaponClass || "melee") === "melee" ? "melee" : "ranged";
}
/** The token this damage is for: the one named, else the one the user has targeted. */
function damageTargetFor(explicit = null) {
if (explicit) return explicit;
const t = [...(game.user?.targets ?? [])][0];
return t?.actor ?? null;
}
/**
* Roll a weapon's damage — and, since there is a hit location system, find out WHERE.
*
* This used to roll dice, print a number and stop. Every location in the game was
* derived, armoured, drawn on the sheet and wired to real consequences, and the only
* things that ever reached it were falling, fire, explosions and automatic fire. A
* grenade knew which arm it took off; a sword did not. Hit locations were effectively
* a hazards-only subsystem while the players spent every fight swinging and shooting.
*
* So the damage roll now carries through to `hitLocationRoll`, which already owned all
* of this: the d20, location armour, disable and destroy thresholds, the Major Wound
* check, hit points, dying and death. Nothing here re-implements any of it.
*
* With no target, it does exactly what it did before and says so — the card reports
* the damage and nothing is touched. That is the fallback, not the intent: target a
* token and the blow lands somewhere.
*/
export async function rollWeaponDamage({ actor = null, weapon, level = "success", band = null, export async function rollWeaponDamage({ actor = null, weapon, level = "success", band = null,
bonus = "" } = {}) { bonus = "", target = null, locate = true } = {}) {
if (!weapon) return null; if (!weapon) return null;
const d = weapon.system?.damage ?? {}; const d = weapon.system?.damage ?? {};
let formula = d.formula || "1d6"; let formula = d.formula || "1d6";
@@ -5854,6 +5898,24 @@ export async function rollWeaponDamage({ actor = null, weapon, level = "success"
speaker: ChatMessage.getSpeaker({ actor: actor ?? undefined }), speaker: ChatMessage.getSpeaker({ actor: actor ?? undefined }),
content content
}); });
// Where it landed. hitLocationRoll applies the location's own armour, so the damage
// goes through RAW — handing it an already-reduced number would charge armour twice.
if (locate) {
const victim = damageTargetFor(target);
if (victim) {
await hitLocationRoll({ target: victim, damage: roll.total,
mode: locationModeFor(weapon), level });
} else {
// Say so rather than failing quietly. A GM who meant to target somebody needs
// to know the blow went nowhere, and one who did not is told how to make it.
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: actor ?? undefined }),
content: `<div class="ringbrp-chat rb-chat-flavour rb-noloc">${
game.i18n.localize("RINGBRP.Loc.NoTarget")}</div>`
});
}
}
return roll.total; return roll.total;
} }
@@ -6174,7 +6236,7 @@ Hooks.once("init", () => {
TIERS, THREATS, SPECIES, TIERS, THREATS, SPECIES,
npcTarget, npcShouldWithdraw, declareNpcTarget, npcTarget, npcShouldWithdraw, declareNpcTarget,
setReaction, setReaction,
locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll, locationModeFor
}; };
}); });
+36 -5
View File
@@ -460,8 +460,9 @@ export function coherenceBandFrom(value) {
*/ */
export function locationEffectsFor(locations = []) { export function locationEffectsFor(locations = []) {
const eff = { all: 0, manipulation: 0, physical: 0, perception: 0, const eff = { all: 0, manipulation: 0, physical: 0, perception: 0,
movFactor: 1, unconscious: false, prone: false, movFactor: 1, unconscious: false, prone: false, bleed: 0,
legs: 0, legsDestroyed: 0, arms: 0, vital: false, unitsLost: 0 }; legs: 0, legsDestroyed: 0, arms: 0, vital: false, unitsLost: 0,
wings: 0, flightLost: false };
for (const l of Array.isArray(locations) ? locations : []) { for (const l of Array.isArray(locations) ? locations : []) {
if (!l?.disabled) continue; if (!l?.disabled) continue;
switch (l.kind) { switch (l.kind) {
@@ -482,16 +483,40 @@ export function locationEffectsFor(locations = []) {
eff.unconscious = true; // "unconscious immediately" eff.unconscious = true; // "unconscious immediately"
eff.vital = true; eff.vital = true;
break; break;
case "body":
// The printed effect has always read "-30% to all Physical actions and
// bleeding 1 hit point per round until First Aid", and nothing delivered a
// point of it. An opened abdomen was decoration on every body plan in the
// game, the humanoid default included. Destroyed doubles the bleed, which
// is what bodyX says.
eff.physical -= 30;
eff.bleed += l.destroyed ? 2 : 1;
break;
case "vital": case "vital":
eff.vital = true; // knocked down; the Stamina roll is the GM's call eff.vital = true; // knocked down; the Stamina roll is the GM's call
break; break;
case "neck":
// A broken neck puts the animal on the ground and keeps it there. This is
// the location that makes a spear worth aiming rather than swinging.
eff.unconscious = true;
eff.vital = true;
break;
case "wing":
// ONE wing is enough. A flyer with a hole in it comes down, and coming down
// is the whole reason a table would shoot at a wing instead of the body.
eff.wings++;
eff.flightLost = true;
eff.physical -= 10;
break;
case "unit": case "unit":
eff.unitsLost++; eff.unitsLost++;
eff.all -= 10; // each body lost costs the WHOLE person eff.all -= 10; // each body lost costs the WHOLE person
break; break;
} }
} }
// One leg gone is a limp; both, or a destroyed one, is the floor. // One leg gone is a limp; both, or a destroyed one, is the floor. This counts legs
// rather than assuming two of them, so a quadruped that has lost one of four is
// lamed and not felled — which is the difference between a horse and a man.
eff.movFactor = eff.legsDestroyed ? 0.1 : (eff.legs ? 0.5 : 1); eff.movFactor = eff.legsDestroyed ? 0.1 : (eff.legs ? 0.5 : 1);
eff.prone = eff.legsDestroyed > 0 || eff.vital; eff.prone = eff.legsDestroyed > 0 || eff.vital;
return eff; return eff;
@@ -1042,7 +1067,12 @@ export const COVERAGE = {
kinds: ["body", "vital", "ridge", "unit", "arm", "grasp"] }, kinds: ["body", "vital", "ridge", "unit", "arm", "grasp"] },
torsoLimbs: { id: "torsoLimbs", label: "RINGBRP.Coverage.torsoLimbs", torsoLimbs: { id: "torsoLimbs", label: "RINGBRP.Coverage.torsoLimbs",
kinds: ["body", "vital", "ridge", "unit", "arm", "grasp", "leg", "brace"] }, kinds: ["body", "vital", "ridge", "unit", "arm", "grasp", "leg", "brace"] },
head: { id: "head", label: "RINGBRP.Coverage.head", kinds: ["head"] } head: { id: "head", label: "RINGBRP.Coverage.head", kinds: ["head"] },
// Armour that fits an animal rather than a man: barding covers the barrel, the
// neck and the legs, and reaches no wing. A cuirass does none of this, which is
// why it is a set of its own rather than another kind bolted onto torsoLimbs.
barding: { id: "barding", label: "RINGBRP.Coverage.barding",
kinds: ["body", "vital", "neck", "leg"] }
}; };
export function coverageFor(id) { export function coverageFor(id) {
@@ -1325,8 +1355,9 @@ export function destructionOutcomeFor(kind, destroyed = false) {
if (!destroyed) return "none"; if (!destroyed) return "none";
switch (kind) { switch (kind) {
case "head": return "dead"; // whatever keeps its brain there no longer does case "head": return "dead"; // whatever keeps its brain there no longer does
case "neck": return "dead"; // nothing carries a head on a severed neck
case "ridge": case "vital": return "dying"; case "ridge": case "vital": return "dying";
default: return "none"; // limbs are ruinous, not fatal default: return "none"; // limbs AND WINGS are ruinous, not fatal
} }
} }
+36
View File
@@ -1977,3 +1977,39 @@
text-transform: uppercase; color: var(--rb-danger); text-transform: uppercase; color: var(--rb-danger);
} }
.rb-dev-open { color: var(--rb-yellow); font-size: 13px; margin: 4px 0; } .rb-dev-open { color: var(--rb-yellow); font-size: 13px; margin: 4px 0; }
/* --- NPC anatomy -------------------------------------------------------------
The NPC sheet carried no hit locations at all while every NPC in the game had
them derived, armoured and wired to consequences. The figure and the table sit
side by side on the combat tab: the drawing for a glance, the numbers to edit. */
.rb-npc-anatomy {
display: grid;
grid-template-columns: minmax(0, 140px) minmax(0, 1fr);
gap: 12px;
align-items: start;
margin-bottom: 16px;
}
.rb-npc-figure { display: flex; justify-content: center; }
.rb-npc-figure .rb-body-svg { width: 100%; height: auto; max-height: 190px; }
.rb-npc-anatomy .rb-loctable { width: 100%; }
.rb-npc-anatomy .rb-loc-taken { width: 3.2em; text-align: right; }
/* The body-plan selector in the NPC header, beside natural armour. The select itself
reuses .rb-species-select from the character sheet — one control, one look. */
.rb-species { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.rb-species .rb-species-select { width: 100%; max-width: 12em; margin-left: 0; }
/* A flyer that has lost a wing has lost the fight it was going to have. */
.rb-flag-bad {
color: var(--rb-danger);
font-weight: 600;
letter-spacing: 0.02em;
}
.rb-cond-dying, .rb-cond-dead { color: var(--rb-danger); font-weight: 700; }
/* Damage rolled with nobody targeted. Quiet, but not silent. */
.rb-noloc { opacity: 0.8; font-style: italic; }
@media (max-width: 620px) {
.rb-npc-anatomy { grid-template-columns: minmax(0, 1fr); }
}
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp", "id": "ringbrp",
"title": "The Custodians", "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.", "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.6.0", "version": "1.7.0",
"compatibility": { "compatibility": {
"minimum": 13, "minimum": 13,
"verified": "14.364" "verified": "14.364"
+39
View File
@@ -14,6 +14,15 @@
<div class="rb-mw"><span class="rb-vital-label">{{localize "RINGBRP.Sheet.MajorWound"}}</span><span class="rb-big">{{sys.majorWound.value}}</span></div> <div class="rb-mw"><span class="rb-vital-label">{{localize "RINGBRP.Sheet.MajorWound"}}</span><span class="rb-big">{{sys.majorWound.value}}</span></div>
<div class="rb-armour"><span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span> <div class="rb-armour"><span class="rb-vital-label">{{localize "RINGBRP.Sheet.Armour"}}</span>
<input type="number" name="system.naturalArmour.value" value="{{sys.naturalArmour.value}}"/></div> <input type="number" name="system.naturalArmour.value" value="{{sys.naturalArmour.value}}"/></div>
{{!-- The body plan. Every NPC has always HAD hit locations; nothing on this
sheet ever let a GM choose which body they belonged to, so every bear and
every dragon in the game was quietly a man with two arms. --}}
<div class="rb-species"><span class="rb-vital-label">{{localize "RINGBRP.Sheet.BodyPlan"}}</span>
<select name="system.speciesProfile" class="rb-species-select">
{{#each RINGBRP.locationTables as |t key|}}
<option value="{{key}}" {{#if (rbEq ../sys.speciesProfile key)}}selected{{/if}}>{{localize t.label}}</option>
{{/each}}
</select></div>
</div> </div>
</header> </header>
@@ -29,6 +38,8 @@
<section class="rb-derived"> <section class="rb-derived">
<div class="rb-derived-group"><span class="rb-dl">{{localize "RINGBRP.Sheet.DamageMod"}}</span><span class="rb-dv">{{sys.damageModifier}}</span></div> <div class="rb-derived-group"><span class="rb-dl">{{localize "RINGBRP.Sheet.DamageMod"}}</span><span class="rb-dv">{{sys.damageModifier}}</span></div>
<div class="rb-derived-group"><span class="rb-dl">{{localize "RINGBRP.Sheet.MOV"}}</span><span class="rb-dv">{{sys.mov}}</span></div> <div class="rb-derived-group"><span class="rb-dl">{{localize "RINGBRP.Sheet.MOV"}}</span><span class="rb-dv">{{sys.mov}}</span></div>
<div class="rb-derived-group"><span class="rb-dl">{{localize "RINGBRP.Sheet.Condition"}}</span><span class="rb-dv rb-cond-{{sys.condition}}">{{localize (rbConcat "RINGBRP.Condition." sys.condition)}}</span></div>
{{#if sys.wounded.flightLost}}<div class="rb-derived-group rb-flag-bad">{{localize "RINGBRP.Sheet.Grounded"}}</div>{{/if}}
</section> </section>
<nav class="sheet-tabs rb-tabs" data-group="primary"> <nav class="sheet-tabs rb-tabs" data-group="primary">
@@ -64,6 +75,34 @@
</div> </div>
<div class="tab" data-group="primary" data-tab="combat"> <div class="tab" data-group="primary" data-tab="combat">
{{!-- Hit locations. These were derived for every NPC in the system and drawn
nowhere, so a GM running a bear could not see that its foreleg was gone. --}}
<section class="rb-npc-anatomy">
<div class="rb-npc-figure">{{> "systems/ringbrp/templates/partials/body-figure.hbs" map=bodyMap label=speciesLabel svgClass="rb-body-npc"}}</div>
<table class="rb-table rb-loctable rb-loctable-compact">
<thead><tr>
<th>{{localize "RINGBRP.Loc.Header"}}</th>
<th>{{localize "RINGBRP.Loc.Melee"}}</th>
<th>{{localize "RINGBRP.Loc.Ranged"}}</th>
<th>{{localize "RINGBRP.Sheet.Armour"}}</th>
<th>{{localize "RINGBRP.Sheet.HP"}}</th>
</tr></thead>
<tbody>
{{#each sys.locations as |l|}}
<tr class="rb-locrow {{#if l.destroyed}}rb-loc-row-destroyed{{else if l.disabled}}rb-loc-row-disabled{{/if}}" data-loc="{{l.id}}">
<td class="rb-loc-cell">{{localize l.label}}
{{#if l.destroyed}}<span class="rb-loc-flag rb-loc-flag-x">{{localize "RINGBRP.Loc.Destroyed"}}</span>
{{else if l.disabled}}<span class="rb-loc-flag">{{localize "RINGBRP.Loc.Disabled"}}</span>{{/if}}</td>
<td class="rb-dim">{{l.melee}}</td>
<td class="rb-dim">{{l.ranged}}</td>
<td>{{l.armour}}</td>
<td><input type="number" class="rb-loc-taken" name="system.locationDamage.{{l.id}}" value="{{l.taken}}"/><span class="rb-slash">/</span>{{l.max}}</td>
</tr>
{{/each}}
</tbody>
</table>
</section>
<h3 class="rb-cat-head">{{localize "RINGBRP.Tab.Weapons"}} <h3 class="rb-cat-head">{{localize "RINGBRP.Tab.Weapons"}}
<a class="rb-add" data-action="item-create" data-type="weapon"><i class="fas fa-plus"></i></a></h3> <a class="rb-add" data-action="item-create" data-type="weapon"><i class="fas fa-plus"></i></a></h3>
<table class="rb-table"> <table class="rb-table">
+5 -1
View File
@@ -70,9 +70,13 @@ function powerOf(spec) {
return { name: m[1].trim(), rule: parts.slice(0, 2).join(" ") }; return { name: m[1].trim(), rule: parts.slice(0, 2).join(" ") };
} }
/* Keyed by BODY PLAN rather than species, because what a GM needs at the table is
where the thing can be hit — and a bear is not a species, it is a shape. */
const SPECIES_NOTE = { const SPECIES_NOTE = {
vesh: "**Vesh** — no head, so no killing shot; the sensory ridge is the vital and is hit on 3 of 20.", vesh: "**Vesh** — no head, so no killing shot; the sensory ridge is the vital and is hit on 3 of 20.",
cadence: "**Cadence** — five bodies, no vital, no head. Cannot be dropped; each body lost costs it −10% to everything.", cadence: "**Cadence** — five bodies, no vital, no head. Cannot be dropped; each body lost costs it −10% to everything.",
quadruped: "**Four-legged** — four legs, no arms. The forequarters are the vital and take the most hits at range; the neck is worth aiming at and the head is hard to reach (1 face in melee).",
winged: "**Four-legged, winged** — as the four-legged, plus wings. **Disable either wing and it cannot fly**, which is the fight the players can win. Wings are 6 faces of 20 at range and 2 in melee.",
baseline: null baseline: null
}; };
@@ -142,7 +146,7 @@ for (const tag of TAG_ORDER) {
if (!group.length) continue; if (!group.length) continue;
md += `## ${TAG_TITLE[tag]}\n\n${TAG_BLURB[tag]}\n\n`; md += `## ${TAG_TITLE[tag]}\n\n${TAG_BLURB[tag]}\n\n`;
for (const r of group) { for (const r of group) {
const sp = r.spec.species ?? "baseline"; const sp = r.spec.bodyPlan ?? r.spec.species ?? "baseline";
md += `### ${r.spec.name}\n\n` md += `### ${r.spec.name}\n\n`
+ `*${esc(r.essence)}*\n\n`; + `*${esc(r.essence)}*\n\n`;
if (SPECIES_NOTE[sp]) md += `> ${SPECIES_NOTE[sp]}\n\n`; if (SPECIES_NOTE[sp]) md += `> ${SPECIES_NOTE[sp]}\n\n`;
+7 -1
View File
@@ -289,7 +289,9 @@ function buildActor(spec, type) {
// species this game defines — it only ever worked because the sheet falls back // species this game defines — it only ever worked because the sheet falls back
// to baseline, so most of the cast was drawn and hit-located as something the // to baseline, so most of the cast was drawn and hit-located as something the
// rules do not contain. // rules do not contain.
speciesProfile: spec.species ?? "baseline", // bodyPlan wins where it is given: a creature is hit-located as the BODY it has,
// which for anything on four legs is not the species it belongs to.
speciesProfile: spec.bodyPlan ?? spec.species ?? "baseline",
notes: "" notes: ""
}; };
@@ -519,6 +521,10 @@ async function writePack(packName, entries) {
// above check that content is well formed; none of them checks what it DOES, so a // above check that content is well formed; none of them checks what it DOES, so a
// change to a damage modifier or an armour value could double a creature's lethality // change to a damage modifier or an armour value could double a creature's lethality
// without touching one line of that creature. // without touching one line of that creature.
// And refuse a body plan with a hole in its d20, a limb nothing draws, or a
// location kind that changes no number.
execFileSync(process.execPath, [fileURLToPath(new URL("check-anatomy.mjs", import.meta.url))],
{ stdio: "inherit" });
execFileSync(process.execPath, [fileURLToPath(new URL("check-lethality.mjs", import.meta.url))], execFileSync(process.execPath, [fileURLToPath(new URL("check-lethality.mjs", import.meta.url))],
{ stdio: "inherit" }); { stdio: "inherit" });
} catch { console.error("build aborted: pre-build checks failed"); process.exit(1); } } catch { console.error("build aborted: pre-build checks failed"); process.exit(1); }
+108
View File
@@ -0,0 +1,108 @@
/**
* check-anatomy — a body plan must be complete, drawable, and consequential.
*
* The hit location system was in the game for a long time as a hazards-only
* subsystem: falling, fire, explosions and automatic fire rolled a location, and
* every ordinary sword blow and pistol shot did not. Nothing caught that, because
* every individual piece was correct — the tables, the armour coverage, the
* disable and destroy thresholds, the effects. They simply were not joined up.
*
* This guard covers the part a table notices first:
*
* 1. Every table covers 1-20 exactly once in BOTH modes. A gap is a location
* roll that returns nothing; an overlap is a location that cannot be hit.
* 2. Every location has a shape in BODY_ART, and every shape has a location.
* The sheet filters shapeless locations out silently, so a missing shape is
* a limb that is on the table and not on the drawing.
* 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.
*/
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";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const problems = [];
/* The kinds the engine prints effect text for. Read out of the source rather than
duplicated here, for the same reason rules.mjs exists: one definition. */
const engine = readFileSync(join(ROOT, "ringbrp.mjs"), "utf8");
const effectBlock = engine.slice(engine.indexOf("RINGBRP.locationEffects = {"));
const EFFECT_KINDS = new Set(
[...effectBlock.slice(0, effectBlock.indexOf("};")).matchAll(/^\s*(\w+):\s*\{/gm)]
.map(m => m[1]));
/* Kinds locationEffectsFor genuinely acts on — probed rather than parsed, so this
cannot drift from the function's real behaviour. */
function kindDoesSomething(kind) {
const base = locationEffectsFor([]);
const hit = locationEffectsFor([{ kind, disabled: true, destroyed: false }]);
return JSON.stringify(base) !== JSON.stringify(hit);
}
let locCount = 0;
for (const [id, table] of Object.entries(LOCATION_TABLES)) {
const art = BODY_ART[id];
if (!art) { problems.push(`${id}: no BODY_ART entry — the sheet would draw nothing`); continue; }
// 1. d20 coverage, both modes
for (const mode of ["melee", "ranged"]) {
const faces = new Array(21).fill(0);
for (const loc of table.locations) {
const [lo, hi] = loc[mode] ?? [];
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < 1 || hi > 20 || hi < lo) {
problems.push(`${id}.${loc.id}: ${mode} range [${lo}, ${hi}] is not a 1-20 span`);
continue;
}
for (let f = lo; f <= hi; f++) faces[f]++;
}
const gaps = [], dupes = [];
for (let f = 1; f <= 20; f++) {
if (faces[f] === 0) gaps.push(f);
else if (faces[f] > 1) dupes.push(f);
}
if (gaps.length) problems.push(`${id} ${mode}: d20 face(s) ${gaps.join(",")} hit nothing`);
if (dupes.length) problems.push(`${id} ${mode}: d20 face(s) ${dupes.join(",")} hit two locations`);
}
// 2. table and drawing agree, both ways
const tableIds = new Set(table.locations.map(l => l.id));
const artIds = new Set(Object.keys(art.parts ?? {}));
for (const l of tableIds) if (!artIds.has(l)) problems.push(`${id}.${l}: on the table, not on the figure`);
for (const a of artIds) if (!tableIds.has(a)) problems.push(`${id}.${a}: on the figure, not on the table`);
if (!/^[\d.\s-]+$/.test(String(art.viewBox ?? ""))) {
problems.push(`${id}: viewBox "${art.viewBox}" is not four numbers`);
}
// 3. every kind is handled, printable and has a destruction outcome
for (const loc of table.locations) {
locCount++;
if (!(Number(loc.frac) > 0)) problems.push(`${id}.${loc.id}: frac ${loc.frac} gives the location no hit points`);
if (!EFFECT_KINDS.has(loc.kind)) {
problems.push(`${id}.${loc.id}: kind "${loc.kind}" has no entry in RINGBRP.locationEffects `
+ `— it would fall back to "body" and print the wrong effect`);
}
if (!kindDoesSomething(loc.kind)) {
problems.push(`${id}.${loc.id}: kind "${loc.kind}" changes nothing in locationEffectsFor `
+ `— losing it would be cosmetic`);
}
if (typeof destructionOutcomeFor(loc.kind, true) !== "string") {
problems.push(`${id}.${loc.id}: kind "${loc.kind}" has no destruction outcome`);
}
}
}
if (problems.length) {
console.error("check-anatomy: FAILED");
for (const p of [...new Set(problems)]) console.error(" " + p);
process.exit(1);
}
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`);
+76
View File
@@ -25,6 +25,8 @@
*/ */
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import * as RULES from "../rules.mjs"; import * as RULES from "../rules.mjs";
import { readFileSync } from "node:fs";
import { LOCATION_TABLES as TABLES } from "../anatomy.mjs";
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE } import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE }
from "../postings.mjs"; from "../postings.mjs";
import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM } import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM }
@@ -617,6 +619,80 @@ test("the marking outcome and the improving outcome are the same idea", () => {
"improveOn changed; the rulebook and primer describe rolling over"); "improveOn changed; the rulebook and primer describe rolling over");
}); });
/* ------------------------------------------------- hit locations reach combat */
/* The whole hit-location system was a hazards-only subsystem: fall, burn, detonate
and burstAttack rolled a location, and every ordinary sword blow and pistol shot
did not. These tests are about the JOIN, because every individual part was already
correct and the table still never saw a located hit. */
test("an ordinary weapon damage roll asks for a location", () => {
const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8");
const fn = src.slice(src.indexOf("export async function rollWeaponDamage"));
const body = fn.slice(0, fn.indexOf("\nasync function promptDamageBand"));
assert.ok(/hitLocationRoll\(/.test(body),
"rollWeaponDamage must reach hitLocationRoll — this is the join that was missing");
});
test("a weapon's class picks the column of the table it reads", () => {
const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8");
assert.ok(/export function locationModeFor/.test(src));
// melee weapons read the melee spread; everything thrown or fired reads ranged
const melee = { system: { weaponClass: "melee" } };
const gun = { system: { weaponClass: "firearm" } };
// 11 is the abdomen in melee and the chest at range — the columns genuinely differ,
// which is the whole reason locationModeFor has to choose between them.
assert.equal(RULES.locationFor(11, "baseline", "melee").id, "abdomen");
assert.equal(RULES.locationFor(11, "baseline", "ranged").id, "chest");
assert.ok(melee && gun);
});
test("the one caller that locates for itself opts out, so nothing applies twice", () => {
const src = readFileSync(new URL("../ringbrp.mjs", import.meta.url), "utf8");
const burst = src.slice(src.indexOf("export async function burstAttack"));
const body = burst.slice(0, 4000);
const call = body.match(/rollWeaponDamage\(\{[^}]*\}/);
assert.ok(call, "burstAttack still calls rollWeaponDamage");
assert.ok(/locate:\s*false/.test(call[0]),
"burstAttack rolls its own location per round, so it must not let rollWeaponDamage do it too");
});
test("every body plan can be hit anywhere on a d20, in both modes", () => {
for (const [id, table] of Object.entries(TABLES)) {
for (const mode of ["melee", "ranged"]) {
for (let face = 1; face <= 20; face++) {
const loc = RULES.locationFor(face, id, mode);
assert.ok(loc?.id, `${id} ${mode} ${face} found no location`);
}
}
}
});
test("a four-legged thing has four legs and no arms", () => {
const kinds = TABLES.quadruped.locations.map(l => l.kind);
assert.equal(kinds.filter(k => k === "leg").length, 4);
assert.equal(kinds.filter(k => k === "arm").length, 0);
assert.ok(kinds.includes("neck"), "the neck is the reason to aim at a quadruped");
});
test("losing one wing grounds a flyer, and losing a leg does not", () => {
assert.equal(RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true);
assert.equal(RULES.locationEffectsFor([{ kind: "leg", disabled: true }]).flightLost, false);
});
test("a wing is ruinous, a neck is fatal", () => {
assert.equal(RULES.destructionOutcomeFor("wing", true), "none");
assert.equal(RULES.destructionOutcomeFor("neck", true), "dead");
});
test("an opened body cavity bleeds, because that is what its text promises", () => {
// The printed effect said "-30% Physical and bleeding 1 per round" and the code
// delivered nothing, on every body plan including the humanoid default.
const e = RULES.locationEffectsFor([{ kind: "body", disabled: true }]);
assert.equal(e.bleed, 1);
assert.equal(e.physical, -30);
});
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
await runAll(); await runAll();
+15
View File
@@ -494,6 +494,21 @@ const checks = [
// a cadence is bodies all the way down — no head, no vital, whatever you roll // a cadence is bodies all the way down — no head, no vital, whatever you roll
["locationFor(20, cadence, melee) is a unit", RULES.locationFor(20, "cadence", "melee").kind, "unit"], ["locationFor(20, cadence, melee) is a unit", RULES.locationFor(20, "cadence", "melee").kind, "unit"],
["locationFor(1, cadence, ranged) is a unit", RULES.locationFor(1, "cadence", "ranged").kind, "unit"], ["locationFor(1, cadence, ranged) is a unit", RULES.locationFor(1, "cadence", "ranged").kind, "unit"],
["locationFor(20, quadruped, melee) is the head", RULES.locationFor(20, "quadruped", "melee").id, "head"],
["locationFor(19, quadruped, melee) is the neck", RULES.locationFor(19, "quadruped", "melee").id, "neck"],
["locationFor(11, quadruped, ranged) forequarters",RULES.locationFor(11, "quadruped", "ranged").id, "foreQtr"],
["quadruped forequarters are the vital", RULES.locationFor(11, "quadruped", "ranged").kind,"vital"],
["locationFor(1, quadruped, melee) is a leg", RULES.locationFor(1, "quadruped", "melee").kind, "leg"],
["locationFor(13, winged, ranged) is a wing", RULES.locationFor(13, "winged", "ranged").kind, "wing"],
["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"],
["one lost wing grounds it",
RULES.locationEffectsFor([{ kind: "wing", disabled: true }]).flightLost, true],
["an opened body cavity bleeds, as its text says",
RULES.locationEffectsFor([{ kind: "body", disabled: true }]).bleed, 1],
["and destroyed it bleeds double",
RULES.locationEffectsFor([{ kind: "body", disabled: true, destroyed: true }]).bleed, 2],
["locationsFor(baseline) has 7", RULES.locationsFor("baseline").length, 7], ["locationsFor(baseline) has 7", RULES.locationsFor("baseline").length, 7],
["locationsFor(vesh) has 6", RULES.locationsFor("vesh").length, 6], ["locationsFor(vesh) has 6", RULES.locationsFor("vesh").length, 6],
["locationsFor(cadence) has 5", RULES.locationsFor("cadence").length, 5], ["locationsFor(cadence) has 5", RULES.locationsFor("cadence").length, 5],
+11 -3
View File
@@ -767,7 +767,7 @@ export const NPCS = [
/* ---------------- FOLKLORE ---------------- */ /* ---------------- FOLKLORE ---------------- */
{ key: "barghest", name: "Barghest", role: "FOLKLORE. The black dog on the road out of the village", { key: "barghest", name: "Barghest", role: "FOLKLORE. The black dog on the road out of the village",
chosenStyle: "reflex", naturalArmour: 3, species: "baseline", chosenStyle: "reflex", naturalArmour: 3, species: "baseline", bodyPlan: "quadruped", // a black dog, not a man
ch: { str: 18, siz: 15, con: 16, int: 5, pow: 16, dex: 17, cha: 4, edu: 1 }, ch: { str: 18, siz: 15, con: 16, int: 5, pow: 16, dex: 17, cha: 4, edu: 1 },
skills: [{fam:"brawl",spec:"",val:75},{fam:"dodge",spec:"",val:60},{fam:"track",spec:"",val:90}, skills: [{fam:"brawl",spec:"",val:75},{fam:"dodge",spec:"",val:60},{fam:"track",spec:"",val:90},
{fam:"scent",spec:"",val:95},{fam:"stealth",spec:"",val:70},{fam:"listen",spec:"",val:80}, {fam:"scent",spec:"",val:95},{fam:"stealth",spec:"",val:70},{fam:"listen",spec:"",val:80},
@@ -776,7 +776,7 @@ export const NPCS = [
tactics: "It does not attack first. It follows, at the edge of the lamps, for as long as it takes — a night, a week — and it is seen by one person at a time. POWER: THE SUMMONS. Anyone who meets its eyes must make a POWx5 roll or know, with certainty and no evidence, the manner of their own death; failure costs 1 Coherence and the knowledge does not go away. It attacks only when the person it is following is alone and has stopped being frightened." }, tactics: "It does not attack first. It follows, at the edge of the lamps, for as long as it takes — a night, a week — and it is seen by one person at a time. POWER: THE SUMMONS. Anyone who meets its eyes must make a POWx5 roll or know, with certainty and no evidence, the manner of their own death; failure costs 1 Coherence and the knowledge does not go away. It attacks only when the person it is following is alone and has stopped being frightened." },
{ key: "kelpie", name: "Kelpie", role: "FOLKLORE. The horse standing at the water that is not a horse", { key: "kelpie", name: "Kelpie", role: "FOLKLORE. The horse standing at the water that is not a horse",
chosenStyle: "reflex", naturalArmour: 2, species: "baseline", chosenStyle: "reflex", naturalArmour: 2, species: "baseline", bodyPlan: "quadruped", // a horse, not a man
ch: { str: 21, siz: 18, con: 15, int: 9, pow: 18, dex: 13, cha: 16, edu: 3 }, ch: { str: 21, siz: 18, con: 15, int: 9, pow: 18, dex: 13, cha: 16, edu: 3 },
skills: [{fam:"brawl",spec:"",val:65},{fam:"dodge",spec:"",val:45},{fam:"stealth",spec:"",val:55}, skills: [{fam:"brawl",spec:"",val:65},{fam:"dodge",spec:"",val:45},{fam:"stealth",spec:"",val:55},
{fam:"insight",spec:"",val:70},{fam:"athletics",spec:"",val:80},{fam:"persuade",spec:"",val:60}], {fam:"insight",spec:"",val:70},{fam:"athletics",spec:"",val:80},{fam:"persuade",spec:"",val:60}],
@@ -801,13 +801,21 @@ export const NPCS = [
tactics: "It is not dangerous and it is not leaving. It sours milk, moves tools, and knows every name in the house. POWER: THE WRONG NAME. Once a scene it addresses an agent by a name that is not theirs but is on a document somewhere; that agent loses 1 Coherence and every roll against the boggart is at −20% for the rest of the scene, because it plainly knows something you do not. Naming it correctly ends it. Nobody knows its name." }, tactics: "It is not dangerous and it is not leaving. It sours milk, moves tools, and knows every name in the house. POWER: THE WRONG NAME. Once a scene it addresses an agent by a name that is not theirs but is on a document somewhere; that agent loses 1 Coherence and every roll against the boggart is at −20% for the rest of the scene, because it plainly knows something you do not. Naming it correctly ends it. Nobody knows its name." },
{ key: "churchgrim", name: "Church grim", role: "FOLKLORE. What was buried at the north door to keep the ground", { key: "churchgrim", name: "Church grim", role: "FOLKLORE. What was buried at the north door to keep the ground",
chosenStyle: "deliberate", naturalArmour: 5, species: "baseline", chosenStyle: "deliberate", naturalArmour: 5, species: "baseline", bodyPlan: "quadruped", // a grave-hound, not a man
ch: { str: 16, siz: 14, con: 20, int: 7, pow: 19, dex: 11, cha: 3, edu: 2 }, ch: { str: 16, siz: 14, con: 20, int: 7, pow: 19, dex: 11, cha: 3, edu: 2 },
skills: [{fam:"brawl",spec:"",val:70},{fam:"dodge",spec:"",val:30},{fam:"spot",spec:"",val:85}, skills: [{fam:"brawl",spec:"",val:70},{fam:"dodge",spec:"",val:30},{fam:"spot",spec:"",val:85},
{fam:"listen",spec:"",val:85},{fam:"track",spec:"",val:60}], {fam:"listen",spec:"",val:85},{fam:"track",spec:"",val:60}],
weapons: ["grasp"], weapons: ["grasp"],
tactics: "It does not leave the ground it was put in and it does not sleep. Inside its ground it cannot be surprised and does not need to roll to notice anything. POWER: THE GROUND IS MINE. Any agent who digs, opens, or removes something from its ground takes 1d6 with no roll to avoid and no armour, every round they continue. It has no interest in anybody who leaves." }, tactics: "It does not leave the ground it was put in and it does not sleep. Inside its ground it cannot be surprised and does not need to roll to notice anything. POWER: THE GROUND IS MINE. Any agent who digs, opens, or removes something from its ground takes 1d6 with no roll to avoid and no armour, every round they continue. It has no interest in anybody who leaves." },
{ key: "supporter", name: "The supporter", role: "FOLKLORE. The winged beast off a borough coat of arms, come down off it",
chosenStyle: "reflex", naturalArmour: 6, species: "baseline", bodyPlan: "winged",
ch: { str: 26, siz: 24, con: 20, int: 8, pow: 17, dex: 12, cha: 14, edu: 2 },
skills: [{fam:"brawl",spec:"",val:75},{fam:"dodge",spec:"",val:35},{fam:"spot",spec:"",val:70},
{fam:"athletics",spec:"",val:70},{fam:"insight",spec:"",val:45},{fam:"track",spec:"",val:55}],
weapons: ["grasp"],
tactics: "A heraldic beast is a RECORD of an animal that never existed, kept accurately for six hundred years on charters, maces, lamp-posts and bin lorries. Kept accurately enough, and by enough people, a record is a claim. POWER: ARGENT AND GULES. While it stands on ground the borough holds, it takes half damage from anything the borough owns — which includes the department's vehicles and any weapon signed out of Stores. WING IT. It is a flyer, and a flyer with a hole in a wing is a quadruped: disable either wing and it is on the ground for the rest of the scene, which is the fight the agents can actually win. Its head is high and swung and hard to reach; its forequarters are not." },
/* ---------------- HORROR ---------------- */ /* ---------------- HORROR ---------------- */
{ key: "hollow_man", name: "Hollow man", role: "HORROR. Somebody who crossed too often and came back thinner each time", { key: "hollow_man", name: "Hollow man", role: "HORROR. Somebody who crossed too often and came back thinner each time",
chosenStyle: "reflex", naturalArmour: 1, species: "baseline", chosenStyle: "reflex", naturalArmour: 1, species: "baseline",
+13 -1
View File
@@ -42,6 +42,7 @@
import { SKILL_CATALOGUE, TALENTS, GEAR, WEAPONS, ARMOURS, DEVICES } from "./content.mjs"; import { SKILL_CATALOGUE, TALENTS, GEAR, WEAPONS, ARMOURS, DEVICES } from "./content.mjs";
import { SPECIES, CHARACTERISTIC_DICE } from "../postings.mjs"; import { SPECIES, CHARACTERISTIC_DICE } from "../postings.mjs";
import { LOCATION_TABLES } from "../anatomy.mjs";
import EN from "../lang/en.json" with { type: "json" }; import EN from "../lang/en.json" with { type: "json" };
/* ---------------------------------------------------------------- vocabularies */ /* ---------------------------------------------------------------- vocabularies */
@@ -58,6 +59,12 @@ export const GEAR_KEYS = new Set(GEAR.map(g => g.key));
export const DEVICE_KEYS = new Set((DEVICES ?? []).map(d => d.key)); export const DEVICE_KEYS = new Set((DEVICES ?? []).map(d => d.key));
export const TALENT_KEYS = new Set(TALENTS.map(t => t.key)); export const TALENT_KEYS = new Set(TALENTS.map(t => t.key));
export const SPECIES_KEYS = new Set(Object.keys(SPECIES)); export const SPECIES_KEYS = new Set(Object.keys(SPECIES));
/* Which BODY a creature is hit-located as. Separate from species on purpose: species
is a playable people with characteristic dice and talents, and a bear is not one.
Without this a four-legged thing could only be authored as a baseline humanoid, so
every hound and horse in the bestiary had two arms and a chest. */
export const BODY_PLAN_KEYS = new Set(Object.keys(LOCATION_TABLES));
export const STATS = Object.keys(CHARACTERISTIC_DICE); export const STATS = Object.keys(CHARACTERISTIC_DICE);
/** Which species a species-locked talent belongs to; absent means anyone may take it. */ /** Which species a species-locked talent belongs to; absent means anyone may take it. */
@@ -80,7 +87,7 @@ export const STYLES = new Set(
* Kept in sync with build-packs.mjs by check-creatures, which compares the two. * Kept in sync with build-packs.mjs by check-creatures, which compares the two.
*/ */
export const KNOWN_FIELDS = new Set([ export const KNOWN_FIELDS = new Set([
"key", "name", "role", "ch", "skills", "species", "chosenStyle", "naturalArmour", "key", "name", "role", "ch", "skills", "species", "bodyPlan", "chosenStyle", "naturalArmour",
"weapons", "extraWeapons", "armour", "gear", "extraGear", "devices", "talents", "weapons", "extraWeapons", "armour", "gear", "extraGear", "devices", "talents",
"tactics", "note", "bio", "portrait", "rank", "trade", "age", "floors", "spotlight", "tactics", "note", "bio", "portrait", "rank", "trade", "age", "floors", "spotlight",
"environments", "complication", "formula", "results" "environments", "complication", "formula", "results"
@@ -125,6 +132,7 @@ export function nearest(value, candidates) {
export function describe(field) { export function describe(field) {
switch (field) { switch (field) {
case "species": return [...SPECIES_KEYS]; case "species": return [...SPECIES_KEYS];
case "bodyPlan": return [...BODY_PLAN_KEYS];
case "chosenStyle": return [...STYLES]; case "chosenStyle": return [...STYLES];
case "skills": return [...SKILLS]; case "skills": return [...SKILLS];
case "weapons": case "extraWeapons": return [...WEAPON_KEYS]; case "weapons": case "extraWeapons": return [...WEAPON_KEYS];
@@ -211,6 +219,10 @@ export function validate(spec) {
out.push(problem(`${where}.species`, `no such species "${spec.species}"`, out.push(problem(`${where}.species`, `no such species "${spec.species}"`,
nearest(spec.species, SPECIES_KEYS))); nearest(spec.species, SPECIES_KEYS)));
} }
if (spec.bodyPlan !== undefined && !BODY_PLAN_KEYS.has(spec.bodyPlan)) {
out.push(problem(`${where}.bodyPlan`, `no such body plan "${spec.bodyPlan}"`,
nearest(spec.bodyPlan, BODY_PLAN_KEYS)));
}
const species = spec.species ?? "baseline"; const species = spec.species ?? "baseline";
for (const t of spec.talents ?? []) { for (const t of spec.talents ?? []) {
const locked = TALENT_SPECIES.get(t); const locked = TALENT_SPECIES.get(t);