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