The firing order, in the bestiary, generated (R-258)

DEFENCE_STEP is -30, so a creature's Dodge is not a percentage it has for a round — it is
one it has once or twice. The median dodge in the book is 45% and goes 45 -> 15 -> 1
inside a single round. No player-facing document said so, which hid a real decision:
what order the party shoots in.

The bestiary now has a section on it. Only a landing hit spends a defence, so a miss
strips nothing; each landing hit makes the next attack of that round easier; therefore
the cheapest reliable weapon fires first and the one you most need to land fires last —
against the supporter, the weapon the borough does not own.

Everything it states is generated from DEFENCE_STEP, resolveBands and the statblocks: the
step, the ladder, the median, the spread (13 of 47 creatures stripped by one landing hit,
25 by two, 9 by three) and the list of the hardest to strip. Negative-tested by setting
the step to -15 and -20; the page rewrites itself both times. No win rates are published:
the ordering measurements come from a table-side harness that ignores wound penalties,
bleeding and dying, so the page states the mechanism and quotes no percentages.

My first draft used the dodgiest creature in the book as the worked example — 80%, three
landing hits to strip — directly beneath a claim that the first attack of the round eats
the dodge. True of a modest dodge, false of a good one, and both were on the same page.

tools/bestiary.mjs --check has existed since the document did, with the comment "(for the
guards)", and no guard ever ran it: a check written and wired to nothing, the same shape
as every rule found computed and never read. That was tolerable while the page restated
statblocks and is not now that it derives from rules — change DEFENCE_STEP and the
document lies to a GM. check-bestiary.mjs is the tenth guard, negative-tested both ways
it can fail: a rule change that staler the page, and a hand-edit of the page.
update-readme flagged it as "run by the build, not advertised" before I listed it, and
the README's count word went from nine to ten by itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-12 23:51:04 +01:00
co-authored by Claude Opus 5
parent 15da21d265
commit f932a79121
8 changed files with 164 additions and 9 deletions
+6 -5
View File
@@ -48,7 +48,7 @@ packs or regenerate art on that machine:
```bash
npm install # pulls classic-level, used to write the LevelDB packs
npm run build # rebuilds packs/ from tools/content.mjs
npm run check # the nine guards; the build refuses to run if they fail
npm run check # the ten guards; the build refuses to run if they fail
```
To update a deployed server: `git pull` and restart Foundry. If the pull touches
@@ -157,7 +157,7 @@ icons/ fonts/ art/ generated art
packs/ built LevelDB compendia (committed — see Deploying)
tools/
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
build-packs.mjs builds packs/ — runs the nine guards first and refuses on failure
build-packs.mjs builds packs/ — runs the ten guards first and refuses on failure
make-icons.mjs draws all 231 icons
rules-text.mjs generates the rules journal FROM rules.mjs
mission.mjs the case generator
@@ -177,18 +177,19 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a
literal, or if an exported rule has no spot-check. The packs used to be built under
one set of numbers and played under another; this makes that impossible to ship.
The build runs all nine guards before it writes anything:
The build runs all ten guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 110 rules, 34 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified
check-rules: OK — 110 rules, 35 files scanned, 3 aliases + 46 constant sets checked, 395 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-lang: OK — en.json, 1018 keys, no leaf/branch collisions
check-templates: OK — 19 templates compile
check-behaviour: OK — 76 behavioural tests
check-scenarios: OK — 10 scenario files, 78 mechanic tags, every skill named resolves against 62 catalogue entries
check-scenarios: OK — 11 scenario files, 78 mechanic tags, every skill named resolves against 62 catalogue entries
check-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys
check-anatomy: OK — 5 body plans, 36 locations, d20 complete in both modes, every location drawn, every kind handled and destructible, 20 plan changes carried 432 wounds with their severity intact, 1 flyer(s) with a survivable landing
check-lethality: OK — 47 creatures, every one fighting exactly as recorded against the frozen party
check-bestiary: OK — 47 creatures, the document matches the game
```
<!-- guards:end -->
+34
View File
@@ -64,6 +64,40 @@ several things that are plainly not human-shaped — see the note at the foot.
---
## Shooting at something that moves
A creature's Dodge is not a percentage it has all round. Every defence it spends
costs it **30 points** on the next one, and the whole thing resets when the
round does. The median dodge in this book is 45%, which goes **45%** → **15%** → **1%**
and is down to the floor after 2 landing hits.
Across 47 creatures: **13** are stripped by 1 landing hit, **25** are stripped by 2 landing hits, **9** are stripped by 3 landing hits.
Three things follow, and together they decide what order a team shoots in:
- **Only a hit spends a defence.** A miss strips nothing, so the agent who leads off
wants a reliable attack rather than a big one.
- **Each landing hit makes the next one easier**, in the same round only. Attacks made
after the defence is gone are attacking something that cannot get out of the way.
- **So the cheapest reliable weapon fires first and the one you most need to land fires
last.** The opening shots are bait; they exist to make the thing move. Against
anything that halves damage from departmental kit, the weapon the department did not
issue is the one that fires last of all.
The ones that need the most stripping before your best shot is worth taking:
- **The margin** — dodge 80%, 3 landing hits to strip
- **Redcap** — dodge 75%, 3 landing hits to strip
- **Lantern man** — dodge 75%, 3 landing hits to strip
- **Hedley Kow** — dodge 70%, 3 landing hits to strip
- **The courier** — dodge 70%, 3 landing hits to strip
It runs the other way too: agents defend on the same ladder, which is why a second
attacker in a round is worth so much more than the first, and why anything that gets
two attacks in before the party acts is far more dangerous than its statblock reads.
---
## Folklore
Older than the department and largely uninterested in it. Most of these are arrangements rather than fights, and the arrangement is nearly always cheaper.
+45
View File
@@ -5436,3 +5436,48 @@ the rule finally lives somewhere it can be said to belong to. The two new export
tripped the spot-check requirement the moment they arrived, which is the authority
working as designed: eight new spot-checks, and the numbers for a special hit against
hide 9 are now stated in exactly one place.
## R-258 — the firing order, and a check that was written and never run
`DEFENCE_STEP` is −30, so a defence is not a percentage a creature has for a round: it is
a percentage it has once or twice. The median dodge in the bestiary is 45% and goes
45 → 15 → 1 within a single round, resetting when the round does. Nothing in any
player-facing document said so, which made a real tactical decision invisible at the
table: **what order the party shoots in.**
Three consequences, all of them derived rather than asserted:
- only a *landing* hit spends a defence, so a miss strips nothing
- each landing hit makes the next attack of that round easier
- therefore the cheapest reliable weapon fires first and the one you most need to land
fires last — against the supporter, that is the weapon the borough does not own
Measured in a table-side harness over 2000 fights per ordering: firing order is worth
**10.7 points** of win rate across the 24 permutations of one party (52.5% worst to 63.2%
best), against **6.7 points** for the concentration effect itself. Leading off with the
only un-halved weapon is the single worst choice available. Those figures are NOT from
`simulate.mjs` and are not published — the harness ignores wound penalties, bleeding and
dying — so the bestiary states the mechanism and the tactic and quotes no win rates.
**What it says is generated.** The step, the ladder, the median, the distribution (13 of
47 creatures stripped by one landing hit, 25 by two, 9 by three) and the list of the
hardest to strip all come from `DEFENCE_STEP`, `resolveBands` and the statblocks.
Negative-tested by setting the step to −15 and −20: the page rewrites itself both times.
**My first draft was wrong in the way this log keeps recording.** It picked the dodgiest
creature in the book as the worked example — 80%, which takes three landing hits to strip
— directly under a sentence claiming the first attack of the round eats the dodge. True
for a modest dodge, false for a good one, and I had written both on the same page. The
general rule (each landing hit strips 30) holds for every creature; the overstatement did
not.
**And the defect underneath it.** `tools/bestiary.mjs --check` has existed since the
document did, complete with the comment "(for the guards)", and no guard ever ran it.
A check written and wired to nothing — the same shape as every rule this project has
found computed and never read. It mattered little while the page merely restated
statblocks. It matters now that the page derives from rules: change `DEFENCE_STEP` and
the document silently lies to a GM. `check-bestiary.mjs` is now the tenth guard.
Negative-tested both ways it can fail: a rule change that makes the page stale, and a
hand-edit of the page itself. `update-readme` caught it as "run by the build, not
advertised" before I had added it to the list, and the README's count word went from nine
to ten on its own, which is R-256 paying for itself.
+1 -1
View File
@@ -10,7 +10,7 @@
"bestiary": "node tools/bestiary.mjs",
"mj": "node tools/mj-queue.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-anatomy.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 && bun tools/check-bestiary.mjs",
"test": "bun run check",
"readme": "bun tools/update-readme.mjs"
},
+1 -1
View File
@@ -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.12",
"version": "1.7.13",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+46 -1
View File
@@ -25,7 +25,8 @@ import { writeFile, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { hitPointsFor, majorWoundFor, locationsFor } from "../rules.mjs";
import { hitPointsFor, majorWoundFor, locationsFor,
resolveBands, defencePenaltyFor, DEFENCE_STEP } from "../rules.mjs";
import { buildCombatant } from "./simulate.mjs";
import { MONSTER_TAGS, carriesNothingManufactured } from "./make-portraits.mjs";
@@ -139,6 +140,50 @@ for (const r of [...rows].sort((a, b) => danger(b) - danger(a))) {
md += `\n**V** = Vesh, **C** = Cadence. Everything else is baseline-human anatomy, including\n`
+ `several things that are plainly not human-shaped — see the note at the foot.\n\n---\n\n`;
/* --- how a defence is spent, which decides what order the party shoots in --- */
{
const dodgeOf = spec => (spec.skills ?? []).find(k => k.fam === "dodge")?.val ?? 0;
const effAfter = (rating, used) =>
resolveBands(rating, { situational: defencePenaltyFor(used) }).effective;
// How many LANDING hits it takes before the thing is defending at the 1% floor.
const stripsIn = rating => {
for (let used = 0; used < 8; used++) if (effAfter(rating, used) <= 1) return used;
return 8;
};
const all = rows.map(r => ({ name: r.spec.name, d: dodgeOf(r.spec) })).filter(x => x.d > 0);
const sorted = [...all].sort((a, b) => a.d - b.d);
const median = sorted[Math.floor(sorted.length / 2)].d;
const ladder = [0, 1, 2].map(u => effAfter(median, u));
const spread = {};
for (const x of all) spread[stripsIn(x.d)] = (spread[stripsIn(x.d)] ?? 0) + 1;
const hardest = [...all].sort((a, b) => b.d - a.d).slice(0, 5);
md += `## Shooting at something that moves\n\n`
+ `A creature's Dodge is not a percentage it has all round. Every defence it spends\n`
+ `costs it **${Math.abs(DEFENCE_STEP)} points** on the next one, and the whole thing resets when the\n`
+ `round does. The median dodge in this book is ${median}%, which goes `
+ `${ladder.map(v => `**${v}%**`).join(" → ")}\n`
+ `and is down to the floor after ${stripsIn(median)} landing hits.\n\n`
+ `Across ${all.length} creatures: `
+ Object.keys(spread).sort().map(k => `**${spread[k]}** are stripped by ${k} landing hit${k === "1" ? "" : "s"}`).join(", ")
+ `.\n\n`
+ `Three things follow, and together they decide what order a team shoots in:\n\n`
+ `- **Only a hit spends a defence.** A miss strips nothing, so the agent who leads off\n`
+ ` wants a reliable attack rather than a big one.\n`
+ `- **Each landing hit makes the next one easier**, in the same round only. Attacks made\n`
+ ` after the defence is gone are attacking something that cannot get out of the way.\n`
+ `- **So the cheapest reliable weapon fires first and the one you most need to land fires\n`
+ ` last.** The opening shots are bait; they exist to make the thing move. Against\n`
+ ` anything that halves damage from departmental kit, the weapon the department did not\n`
+ ` issue is the one that fires last of all.\n\n`
+ `The ones that need the most stripping before your best shot is worth taking:\n\n`
+ hardest.map(x => `- **${esc(x.name)}** — dodge ${x.d}%, ${stripsIn(x.d)} landing hits to strip\n`).join("")
+ `\nIt runs the other way too: agents defend on the same ladder, which is why a second\n`
+ `attacker in a round is worth so much more than the first, and why anything that gets\n`
+ `two attacks in before the party acts is far more dangerous than its statblock reads.\n\n---\n\n`;
}
/* --- the entries themselves --- */
for (const tag of TAG_ORDER) {
+29
View File
@@ -0,0 +1,29 @@
/**
* The bestiary document must agree with the game it describes.
*
* `tools/bestiary.mjs --check` has existed since the document did, and nothing ever ran
* it — a check written, wired to nothing, which is the exact defect this project keeps
* finding in its own rules. It mattered little while the page only restated statblocks.
* It matters now: R-258 added a section whose numbers come from DEFENCE_STEP and
* resolveBands, so changing a rule silently falsifies the page a GM reads at the table.
*
* This is a thin guard on purpose. The comparison lives in bestiary.mjs, which is the
* thing that knows how to build the document; re-deriving it here would be the
* duplication check-rules exists to forbid.
*/
import { spawnSync } from "node:child_process";
const r = spawnSync(process.execPath, [new URL("bestiary.mjs", import.meta.url).pathname, "--check"],
{ encoding: "utf8" });
if (r.status !== 0) {
process.stdout.write(r.stdout ?? "");
process.stderr.write(r.stderr ?? "");
console.error("check-bestiary: FAILED — docs/BESTIARY.md no longer matches the game. Run `npm run bestiary`.");
process.exit(1);
}
// Speak in the same format as the other nine: update-readme reads the line that starts
// with this file's own name, and a guard that reports under a different name is a guard
// the README cannot quote.
const n = (r.stdout.match(/(\d+) creatures/) ?? [])[1] ?? "?";
console.log(`check-bestiary: OK — ${n} creatures, the document matches the game`);
+2 -1
View File
@@ -35,7 +35,8 @@ const runGuard = name => {
every guard added after it was written — including the three that catch the most. */
const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs",
"check-templates.mjs", "check-behaviour.mjs", "check-scenarios.mjs",
"check-creatures.mjs", "check-anatomy.mjs", "check-lethality.mjs"];
"check-creatures.mjs", "check-anatomy.mjs", "check-lethality.mjs",
"check-bestiary.mjs"];
/* The list above went stale the moment a guard was added without touching this file —
which is what the comment above it already warned about, and which happened again