From f932a79121c6f8127b093b7a3244e2cf857f19bf Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:51:04 +0100 Subject: [PATCH] The firing order, in the bestiary, generated (R-258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 11 +++++----- docs/BESTIARY.md | 34 +++++++++++++++++++++++++++++ docs/REVIEW_LOG.md | 45 ++++++++++++++++++++++++++++++++++++++ package.json | 2 +- system.json | 2 +- tools/bestiary.mjs | 47 +++++++++++++++++++++++++++++++++++++++- tools/check-bestiary.mjs | 29 +++++++++++++++++++++++++ tools/update-readme.mjs | 3 ++- 8 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 tools/check-bestiary.mjs diff --git a/README.md b/README.md index cc7352e..c46678d 100644 --- a/README.md +++ b/README.md @@ -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: ``` -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 ``` diff --git a/docs/BESTIARY.md b/docs/BESTIARY.md index cc0a531..9b338f2 100644 --- a/docs/BESTIARY.md +++ b/docs/BESTIARY.md @@ -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. diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 4958d30..b764658 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -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. diff --git a/package.json b/package.json index c6f3940..2edaa2b 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/system.json b/system.json index 4fe949b..8191ebe 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "ringbrp", "title": "The Custodians", "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.", - "version": "1.7.12", + "version": "1.7.13", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/tools/bestiary.mjs b/tools/bestiary.mjs index bce967b..9f8c44f 100644 --- a/tools/bestiary.mjs +++ b/tools/bestiary.mjs @@ -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) { diff --git a/tools/check-bestiary.mjs b/tools/check-bestiary.mjs new file mode 100644 index 0000000..3398420 --- /dev/null +++ b/tools/check-bestiary.mjs @@ -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`); diff --git a/tools/update-readme.mjs b/tools/update-readme.mjs index 8450f0e..7236afd 100644 --- a/tools/update-readme.mjs +++ b/tools/update-readme.mjs @@ -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