Files
RingBRP/tools/bestiary.mjs
T
slaguru666andClaude Opus 5 f68b98190f bestiary + check-powers: read the seam, and classify what that reveals
bestiary.mjs built the document from NPCS, so it described 47 creatures of
147 while check-bestiary agreed with it — two things agreeing is not
verification when both read the same wrong source. It reads collectSpecs()
now: 184 creatures across 5 sections, every one with a measured lethality
figure, which only works because check-lethality was fixed first.

Three faults surfaced fixing it:

  - a second NPCS reference in focusParagraph()
  - which then broke on `rows`, because focusParagraph declares its own
    local `rows` for focus-fire data and shadowed the creature list. Keyed
    lookup now, so the shadowing cannot bite again.
  - the document grouped on the raw role tag against a list of four, and
    `if (!group.length) continue` DROPS an unlisted tag rather than
    mis-filing it. Nine of the twelve tags were absent, so those creatures
    would not have appeared at all. Twelve tags now map onto five sections,
    with a new "Beasts and heraldry", and a completeness assertion counts
    every creature into a section and fails if one is unplaced.

check-powers was the fifth consumer bypassing all-specs.mjs. It reconciled
its counts against BESTIARY while reading NPCS, so it called the document
wrong when the document was right.

Reading the real population then demanded 101 classifications. All 101 are
done and NOT ONE was wired. That is the right answer, not a shortfall:
the three available effects describe the creature — its own attack rating,
the damage it takes, whether it feels defence stacking — and essentially
every power is conditional on a place, a posture, a round count, a weapon
type, an attacker count, a named agent, or Coherence. R-275 records what
wiring one anyway costs: 25.1% to 75.2% on a single creature.

The near-misses are the evidence the reluctance was applied, not recited:
tangie's "HALF damage" is edged-only and ends in open flame; the_heron is
+30% or -20% depending on movement the harness does not have, so which
branch applies is undecidable; mirrorline's halving lands on an AGENT, not
on the creature, so wiring it would have protected the wrong combatant;
yale's defence waiver is capped at two attackers and its written counter is
a fourth agent, which defenceStacking:"ignores" cannot express.

Classifications live in four band files and are spread into POWERS last, so
a hand-written entry always wins a collision. 142 classified: 2 wired, 78
beyond the harness with a reason, 62 not fight rules.

All 23 guards pass.

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

389 lines
22 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 { collectSpecs } from "./all-specs.mjs";
import { POWERS, combatPowerFor } from "./powers.mjs";
const wiredPowers = Object.values(POWERS).filter(p => p.kind === "wired").length;
const unwiredFightPowers = Object.values(POWERS).filter(p => p.kind === "notSimulable").length;
const outOfCombatPowers = Object.values(POWERS).filter(p => p.kind === "outOfCombat").length;
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", "BEAST", "HORROR", "FORWARD", ""];
const TAG_TITLE = {
FOLKLORE: "Folklore",
BEAST: "Beasts and heraldry",
HORROR: "Horror",
FORWARD: "The far side",
"": "Uncategorised"
};
/* Twelve role tags, five sections.
*
* The document grouped on the raw tag against a list of four, and `if (!group.length)
* continue` meant any tag not on that list was not mis-filed — it was DROPPED. Nine of
* the twelve tags the bestiary now uses were absent, so those creatures would simply not
* have appeared, in a document whose entire purpose is that a GM can look at them.
*
* Unmapped tags fall to "" rather than vanishing, and the completeness assertion below
* makes dropping one impossible rather than unlikely. */
const SECTION_OF = {
FOLKLORE: "FOLKLORE",
HERALDRY: "BEAST", BEAST: "BEAST", LIVESTOCK: "BEAST",
MENAGERIE: "BEAST", HYBRID: "BEAST",
HORROR: "HORROR", ANOMALOUS: "HORROR", REVENANT: "HORROR", ECHO: "HORROR",
FORWARD: "FORWARD", "FAR-SIDE": "FORWARD"
};
const sectionOf = tag => SECTION_OF[tag] ?? "";
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.",
BEAST: "Animals, and the things off the coats of arms that are shaped like animals. "
+ "Nearly all of these are hit-located as quadrupeds or winged beasts rather than as "
+ "people, so a wing or a foreleg is a real place to aim.",
"": "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) {
/* `POWER\s*:` — the marker stays uppercase, because `power:` occurs in ordinary tactics
prose and matching it would invent powers, but a space before the colon must not hide one.
check-powers carries the counter that names the spellings this deliberately refuses. */
const m = String(spec.tactics ?? "").match(/POWER\s*:\s*([A-Z][A-Z '’-]*)\.\s*([^]*?)(?=\s*(?:POWER\s*:|$))/);
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
};
}
/* Read the shared list, not content.mjs.
*
* This built the document from NPCS, so when the bestiary moved into its own files it
* went on describing 47 creatures out of 147 and check-bestiary agreed with it, because
* the guard compares the document against this generator rather than against the game.
* Two things agreeing with each other is not verification when both read the same wrong
* source. Scenario casts come in too, as they do for check-lethality: the Uncategorised
* section already exists for "people, mostly", which is what they are. */
const rows = (await collectSpecs()).filter(r => r.kind === "npc").map(r => rowFor(r.spec));
/* focusParagraph() declares its own local `rows` for the focus-fire data, so the creature
list is not reachable by that name inside it. Keyed lookup rather than a search, so the
shadowing cannot bite again. */
const nameByKey = new Map(rows.map(r => [r.spec.key, r.spec.name]));
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 = nameByKey.get(bestKey) ?? 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} packs in this book whose odds leave room for a difference to show`
+ `${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`
+ `A pack is measured when ${focus.admission ?? "its odds can move"}, so a fight nobody could lose\n`
+ `and a fight nobody could win are both left out.)*\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;
};
/* A creature whose power exempts it from the stacking penalty is not on this ladder at
all, and listing it among the ones that "take the most stripping" told a GM the exact
opposite of what its own entry says three pages down. R-275 wired NOT TIRED into the
simulator and left this section describing the behaviour it replaced. */
const exempt = rows.map(r => r.spec)
.filter(sp => combatPowerFor(sp.key)?.defenceStacking === "ignores")
.map(sp => ({ name: sp.name, d: dodgeOf(sp), power: POWERS[sp.key].name }));
const exemptNames = new Set(exempt.map(x => x.name));
const all = rows.map(r => ({ name: r.spec.name, d: dodgeOf(r.spec) }))
.filter(x => x.d > 0 && !exemptNames.has(x.name));
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 that spend defences: `
+ 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()
+ (exempt.length
? exempt.map(x => `**${esc(x.name)} is not on this ladder at all.** ${x.power} means it `
+ `never spends a defence: every dodge it makes is at ${x.d}%, however many have landed `
+ `that round. Stripping is not a plan against it — killing it is.\n\n`).join("")
: "")
+ `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 --- */
/* Nothing may be dropped.
*
* sectionOf() falls back to "" and "" is in TAG_ORDER, so at the moment nothing can be.
* That is an argument, not a guarantee: it stops holding the day somebody reorders
* TAG_ORDER or adds a section and forgets the fallback, and the failure mode is a
* creature quietly missing from a reference document — exactly what was happening
* before, and exactly the kind of thing nobody notices. Count them instead. */
{
const placed = new Set(TAG_ORDER.flatMap(tag =>
rows.filter(r => sectionOf(r.tag) === tag).map(r => r.spec.key)));
const dropped = rows.filter(r => !placed.has(r.spec.key));
if (dropped.length) {
console.error(`bestiary: FAILED — ${dropped.length} creature(s) belong to no section and `
+ `would not appear in the document: `
+ dropped.map(r => `${r.spec.key} (tag "${r.tag}")`).join(", "));
process.exit(1);
}
}
for (const tag of TAG_ORDER) {
const group = rows.filter(r => sectionOf(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`;
/* What the simulator does about that power, stated as a number rather than left in the
prose. "HALF the rating above" is a rule a GM must do arithmetic on and a guard
cannot check; 40% is both. check-powers requires this line for every factored
attack, so the page cannot go back to describing the unmodified creature. */
const fx = combatPowerFor(r.spec.key);
if (fx?.attackFactor && r.arm) {
md += `*In play that is **${Math.floor(r.arm.rating * fx.attackFactor)}%**, not `
+ `${r.arm.rating}% — the simulator fights it this way and every figure below is `
+ `measured with the power on.*\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`
/* Counted from powers.mjs rather than stated, because the last time this paragraph
described what the harness could do it was a year out of date within a session. */
+ `**And it fights most of these creatures without their powers.** ${wiredPowers} of the\n`
+ `${wiredPowers + unwiredFightPowers + outOfCombatPowers} POWERS in this book are in the\n`
+ `simulator; **${unwiredFightPowers}** are fight rules it cannot express — a grapple into\n`
+ `drowning, a Coherence drain, an initiative it takes by right — and the rest are not\n`
+ `fight rules at all. Every figure above for one of those ${unwiredFightPowers} is the\n`
+ `creature with its best trick taken away. \`tools/powers.mjs\` says which is which and why.\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 => sectionOf(r.tag) === t)).length} groupings, `
+ `${rows.filter(r => r.measured).length} with measured lethality`);