Files
RingBRP/tools/bestiary.mjs
T
slaguru666andClaude Opus 5 1d915c55cf check-focus: the focus-fire claim is an artifact, not a sentence (R-261)
R-258 put a tactic on a GM-facing page from an unguarded harness, R-259 found it worth
nothing, R-260 found the replacement right for the wrong reason. Three corrections, each
landing as prose with nothing checking it — the arrangement that let ddc4f99 describe a
game nobody was playing.

tools/focus-baseline.json records, per creature, the pack size and the spread-fire and
focus-fire win rates. check-focus re-measures all of it on every build and compares
exactly, with the party, seeds, run count and seeding scheme recorded alongside so numbers
taken under different conditions are refused rather than compared. The bestiary READS the
artifact instead of restating it, and check-bestiary refuses a page that has fallen behind
it. Page, guard and simulator cannot disagree.

The pack size is recorded rather than re-chosen: a fight at 0% or 100% cannot show an
effect, and a guard that picked again each run would let a changed creature move quietly
to a different question and pass. 31 of 47 creatures land in the measurable band; the
other 16 are recorded as pinned, with the rate that pinned them.

It guards the claim as well as the numbers. The page says focus fire helps in every fight
in doubt; check-focus fails if any row's gain reaches zero or stops clearing its own
noise. That failure means rewrite the page, not re-record the baseline.

The run count was chosen by evidence. 1000 x 3 seeds costs 8.5s and takes the suite from
2.5s to 13.7s. I tried 500 to halve it and the claim-check failed — at 500 runs one row
no longer clears its noise, so "without exception" is not supported by that much
sampling. Recording twice at 1000 gives byte-identical files.

Negative-tested four ways, all firing: a creature quietly made nimbler (redcap dodge
75 -> 85, spread 40.6% -> 27.6%), a baseline under different seeds, a hand-edited page,
and the claim failing at 500 runs.

Guard eleven (check-rollable) arrived from another session mid-build; this is twelve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:13:28 +01:00

292 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The bestiary as a document a GM can read before a session.
*
* There are forty-six creatures and, until this, no way to look at them. They live in
* content.mjs, which is source code, and in the compendium, which shows one actor at a
* time behind two clicks. A GM prepping a case cannot browse either, so in practice the
* bestiary is whatever the GM happens to remember writing.
*
* Generated, never hand-written, for the reason the rules journal is generated from
* rules.mjs: a reference that can disagree with the thing it references is worse than
* no reference. Every number here is derived from the spec or read from the lethality
* baseline, so the document cannot drift from the game.
*
* node tools/bestiary.mjs write docs/BESTIARY.md
* node tools/bestiary.mjs --check fail if it is out of date (for the guards)
*
* The measured column comes from tools/lethality-baseline.json — the same frozen party
* check-lethality uses — so a GM reading "2.1 of 4 down" is reading a number the build
* refuses to let drift. It is a SOLO figure against one fixed party: read it to compare
* creatures with each other, never as a prediction of your table. Encounter lethality
* depends on how many there are and who the players brought, and `simulate --spread`
* exists because that second one swings a fight by up to a hundred points.
*/
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,
resolveBands, defencePenaltyFor, DEFENCE_STEP } from "../rules.mjs";
import { buildCombatant } from "./simulate.mjs";
import { MONSTER_TAGS, carriesNothingManufactured } from "./make-portraits.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const OUT = path.join(ROOT, "docs", "BESTIARY.md");
const BASELINE = path.join(ROOT, "tools", "lethality-baseline.json");
const FOCUS = path.join(ROOT, "tools", "focus-baseline.json");
const CHECK = process.argv.includes("--check");
const base = existsSync(BASELINE)
? JSON.parse(await readFile(BASELINE, "utf8"))
: { creatures: {}, party: [], runs: 0, seed: 0 };
/* R-261. The focus-fire numbers below are READ, not restated: check-focus re-measures
them on every build and check-bestiary refuses a page that has fallen behind them. The
three previous attempts to say this in prose were wrong twice. */
const focus = existsSync(FOCUS)
? JSON.parse(await readFile(FOCUS, "utf8"))
: { creatures: {}, runs: 0, seeds: [] };
/* ---------------------------------------------------------------- shaping */
const TAG_ORDER = ["FOLKLORE", "HORROR", "FORWARD", ""];
const TAG_TITLE = {
FOLKLORE: "Folklore",
HORROR: "Horror",
FORWARD: "The far side",
"": "Uncategorised"
};
const TAG_BLURB = {
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.",
HORROR: "The department's own. Every one is a piece of administration that kept going "
+ "after the reason for it stopped.",
FORWARD: "The far side, and what it sends. This is where the non-human anatomies are: "
+ "a Vesh has no head to shoot off, a Cadence has no vital at all.",
"": "People, mostly — contractors, locals, and the ones who were already here."
};
const tagOf = spec => (String(spec.role ?? "").match(/^([A-Z]{3,})\b/) ?? [, ""])[1] ?? "";
const essenceOf = spec => String(spec.role ?? "").replace(/^[A-Z]{3,}[^.]*\.\s*/, "").trim() || String(spec.role ?? "");
/** The POWER line is the mechanic a GM needs in front of them; the rest is staging. */
function powerOf(spec) {
const m = String(spec.tactics ?? "").match(/POWER:\s*([A-Z][A-Z '’-]*)\.\s*([^]*?)(?=\s*(?:POWER:|$))/);
if (!m) return null;
let body = m[2].trim().replace(/\s+/g, " ");
// Trim to the first two sentences: the rule, and the qualification on it.
const parts = body.split(/(?<=[.!?])\s+/);
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 = {
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.",
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, and carry no hide at all. Once it is grounded the hide halves everywhere except the chest, which it keeps turned towards you.",
baseline: null
};
function rowFor(spec) {
const c = buildCombatant(spec, { side: "enemy" });
const arm = c.arms[0];
const measured = base.creatures[spec.key] ?? null;
return {
spec, c, arm, measured,
tag: tagOf(spec),
essence: essenceOf(spec),
power: powerOf(spec),
hp: hitPointsFor(spec.ch.con, spec.ch.siz),
locs: locationsFor(spec.species ?? "baseline").length
};
}
const rows = NPCS.map(rowFor);
const danger = r => r.measured ? r.measured.down : -1;
/* Which of them are the wrong shape.
*
* Counting "baseline creatures that carry a category tag" was the first attempt and it
* over-counted badly: the locum and the predecessor are tagged HORROR and are both
* shaped exactly like a man in a lanyard. The structural test is the one mj-queue uses —
* a monster is a thing that was issued nothing by anybody and fights with its own body —
* which catches the black dog and the case file and leaves the people alone. */
const unmodelled = rows.filter(r =>
(r.spec.species ?? "baseline") === "baseline"
&& MONSTER_TAGS.includes(r.tag)
&& carriesNothingManufactured(r.spec));
/* ---------------------------------------------------------------- document */
const esc = s => String(s).replace(/\|/g, "\\|");
const pad2 = n => n.toFixed(2);
let md = `# THE CUSTODIANS — bestiary\n\n`
+ `*Generated by \`tools/bestiary.mjs\`. Do not edit: every number is derived from the\n`
+ `statblock or read from the lethality baseline, so this cannot disagree with the game.*\n\n`
+ `${rows.length} creatures. ${rows.filter(r => (r.spec.species ?? "baseline") !== "baseline").length} of them are not human-shaped.\n\n`;
/* --- the one-page table, which is the part that gets printed --- */
md += `## At a glance\n\n`
+ `Sorted by how much of a four-agent party each one puts on the floor, alone. `
+ `**Solo figures against one fixed party** — compare creatures with each other, never `
+ `read them as a prediction of your table. See the note at the foot.\n\n`
+ `| | Creature | Essence | SIZ | Arm | HP | Attack | Down of 4 |\n`
+ `|---|---|---|---|---|---|---|---|\n`;
for (const r of [...rows].sort((a, b) => danger(b) - danger(a))) {
const sp = (r.spec.species ?? "baseline");
const mark = sp === "vesh" ? "V" : sp === "cadence" ? "C" : "";
md += `| ${mark} | **${esc(r.spec.name)}** | ${esc(r.essence)} | ${r.spec.ch.siz} | `
+ `${Number(r.spec.naturalArmour) || 0} | ${r.hp} | ${esc(r.arm?.w.name ?? "—")} ${r.arm?.rating ?? 0}% | `
+ `${r.measured ? pad2(r.measured.down) : "—"} |\n`;
}
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 --- */
function focusParagraph() {
const rows = Object.values(focus.creatures ?? {}).filter(r => r.gain !== undefined);
if (!rows.length) return "";
const mean = a => a.reduce((x, y) => x + y, 0) / a.length;
const pairs = rows.filter(r => r.n === 2), packs = rows.filter(r => r.n >= 3);
const helped = rows.filter(r => r.gain > 0).length;
const clear = rows.filter(r => r.gain > r.noise).length;
const best = rows.reduce((a, b) => (b.gain > a.gain ? b : a));
const bestKey = Object.keys(focus.creatures).find(k => focus.creatures[k] === best);
const bestName = (NPCS.find(n => n.key === bestKey) ?? {}).name ?? bestKey;
const every = helped === rows.length && clear === rows.length;
return `**What you can do is kill them one at a time.** Against a pack, every agent putting\n`
+ `their attacks into the same creature beats spreading them \u2014 in ${every ? "**every one**" : `${helped}`} of the\n`
+ `${rows.length} fights in this book whose outcome was ever in doubt${every ? ", without exception" : ""}, and by more\n`
+ `than the measurement\u2019s own noise ${every ? "each time" : `in ${clear} of them`}. It is worth about\n`
+ `**${mean(pairs.map(r => r.gain)).toFixed(0)} points** of win rate against a pair and\n`
+ `**${mean(packs.map(r => r.gain)).toFixed(0)} points** against three or more; the best case in the book is\n`
+ `${best.n} \u00d7 ${esc(bestName)}, which goes ${best.spread.toFixed(1)}% to ${best.focus.toFixed(1)}%. Most of that is not the ladder\n`
+ `above: a creature that is dead stops attacking, and one on half its hit points does not\n`
+ `attack any less. Held at a fixed pack size the dodge ladder is worth a point or two.\n\n`
+ `*(${focus.runs} fights \u00d7 ${(focus.seeds ?? []).length} seeds per arm, re-measured by \`check-focus\` on every build.)*\n\n`;
}
{
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`
+ `Two things follow:\n\n`
+ `- **Only a hit spends a defence.** A miss strips nothing, so a round in which the\n`
+ ` party misses twice is one where the creature meets the third shot at full value.\n`
+ `- **Each landing hit makes the next one easier**, in the same round only. The second\n`
+ ` agent to connect is worth more than the first, and against the heavy dodgers below\n`
+ ` the third is worth more still.\n\n`
+ `**What you cannot do is choose who goes first.** Reaction is re-rolled every round, so\n`
+ `over a fight every agent takes the opening slot about equally often, and there is no\n`
+ `rule for holding an action. Arranging the party so its best weapon fires last was\n`
+ `measured in the simulator and came to nothing distinguishable from noise.\n\n`
+ focusParagraph()
+ `The ones that take the most stripping:\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) {
const group = rows.filter(r => r.tag === tag).sort((a, b) => danger(b) - danger(a));
if (!group.length) continue;
md += `## ${TAG_TITLE[tag]}\n\n${TAG_BLURB[tag]}\n\n`;
for (const r of group) {
const sp = r.spec.bodyPlan ?? r.spec.species ?? "baseline";
md += `### ${r.spec.name}\n\n`
+ `*${esc(r.essence)}*\n\n`;
if (SPECIES_NOTE[sp]) md += `> ${SPECIES_NOTE[sp]}\n\n`;
md += `| STR | CON | SIZ | INT | POW | DEX | Armour | HP | Major wound | Locations |\n`
+ `|---|---|---|---|---|---|---|---|---|---|\n`
+ `| ${r.spec.ch.str} | ${r.spec.ch.con} | ${r.spec.ch.siz} | ${r.spec.ch.int} | `
+ `${r.spec.ch.pow} | ${r.spec.ch.dex} | ${Number(r.spec.naturalArmour) || 0} | ${r.hp} | `
+ `${majorWoundFor(r.hp)} | ${r.locs} |\n\n`;
md += `**Attacks with** ${r.arm ? `${r.arm.w.name} at ${r.arm.rating}%, ${r.arm.w.dmg}` : "nothing"}`
+ `${r.c.dodge ? ` · **dodges** ${r.c.dodge}%` : ""}\n\n`;
if (r.power) md += `**${r.power.name}.** ${esc(r.power.rule)}\n\n`;
if (r.measured) {
md += `*Measured alone against the frozen party: ${pad2(r.measured.down)} of 4 down, `
+ `${r.measured.wipe}% wiped, ${r.measured.rounds} rounds.*\n\n`;
}
}
md += `---\n\n`;
}
/* --- the caveats, which matter more than the numbers --- */
md += `## Reading the measured column\n\n`
+ `Every figure comes from \`tools/lethality-baseline.json\`, measured over ${base.runs} runs\n`
+ `at seed ${base.seed}${base.seedMode === "per-creature"
? ", one sequence derived per creature so no two share a draw," : ","}\n`
+ `against a frozen party of ${base.party.length}: ${base.party.map(k => k.replace(/^pc_/, "")).join(", ")}.\n`
+ `\`check-lethality\` refuses the build if any of them drifts, so these cannot go stale.\n\n`
+ `**They are solo figures and they are not a prediction.** Two things move them hard:\n\n`
+ `- **How many there are.** One Keeper is nothing; six are a 36% chance of a wiped party.\n`
+ `- **Who the players brought.** This is the big one. The same fight measured across every\n`
+ ` party the duty roster can field runs from 0% to 100% wiped — Okonkwo alone is worth\n`
+ ` forty-three points. Run \`node tools/simulate.mjs --creature <key> --count N --spread\`\n`
+ ` before you decide a fight is survivable.\n\n`
+ `The harness models banding, graded defences, hit locations, major wounds and the dying\n`
+ `clock. It does **not** model bleeding, panic, Coherence, cover, range bands or fire\n`
+ `modes, and every one of those makes a fight worse for whoever is losing. Read all of it\n`
+ `as a floor.\n\n`
+ `## A known gap\n\n`
+ `${unmodelled.length} creatures fight with nothing but their own bodies and still carry\n`
+ `baseline-human anatomy. Some of those are people and that is fine. Several plainly are\n`
+ `not — a black dog, a case file that eats the cases filed next to it, a machine that has\n`
+ `decided it is the site — and all of them are hit-located as people, so you can shoot the\n`
+ `barghest in the left arm and open the carrion file's chest.\n\n`
+ `The machinery to fix it exists, works, and is measured: see any Vesh or Cadence entry\n`
+ `above. What is missing is the anatomy tables for the shapes those creatures actually\n`
+ `are — a quadruped, an immobile object, a swarm — and the statblocks to go with them.\n\n`
+ `*Private convention play materials — not for sale or distribution.*\n`;
/* ---------------------------------------------------------------- write */
if (CHECK) {
const existing = existsSync(OUT) ? await readFile(OUT, "utf8") : "";
if (existing !== md) {
console.error("bestiary: FAILED — docs/BESTIARY.md is out of date. Run `npm run bestiary`.");
process.exit(1);
}
console.log(`bestiary: OK — ${rows.length} creatures, document current`);
process.exit(0);
}
await writeFile(OUT, md, "utf8");
console.log(`bestiary: wrote ${path.relative(ROOT, OUT)} — ${rows.length} creatures across `
+ `${TAG_ORDER.filter(t => rows.some(r => r.tag === t)).length} groupings, `
+ `${rows.filter(r => r.measured).length} with measured lethality`);