R-254 shipped the belly rule and called +6.4 points "modest on purpose". That read the
wrong statistic. +6.4 measured whether grounding helps; the question was whether the
borough still matters, and the gap between fighting the supporter on borough ground and
off it had fallen from 60.6 points to 44.9. Half damage from every departmental weapon is
meant to be the fight's central problem, and I had quietly taken sixteen points off it
while reporting a small number about something else. Same error as R-251.
Three changes that only work together:
- hide 7 -> 9, restoring the weight on the axis that should hurt
- grounded still halves it, 9 -> 4, which an ordinary round beats
- exposedHideFor now takes the location's kind and leaves `vital` unhalved: a beast on
its belly is not presenting its chest to the floor
Measured: 28.4% on borough ground flying, 68.4% grounded, 84.6% off it — a 56.2 point gap
against 44.9 before. Predicted 28.4 / 84.3 / 55.9 before building.
The composition of the three hide functions lives in prepareDerivedData where no test
could reach it, and check-anatomy's guard 6 is deliberately one-sided so it would not
have caught the vital losing its exemption. check-behaviour now composes the same
expression from the real supporter spec and the real tables. Negative-tested four ways:
removing the exemption, un-baring the wings, removing the halving, and inflating the hide
past what a round can beat each fail it with the right message.
Cost: the supporter goes from fifth to second deadliest creature in the book (2.84 -> 3.38
down, 46.8% -> 69.3% wiped). check-lethality refused the build until that row was
deliberately re-recorded.
BESTIARY.md printed the new hide and said nothing about it halving, because the body-plan
blurb predates the rule. The README said "eight guards" in three sentences above a
generated block listing nine. Both are the signature defect in prose: a number written
down that nothing reads. Both now generate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
11 KiB
JavaScript
214 lines
11 KiB
JavaScript
/**
|
||
* 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 } 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 CHECK = process.argv.includes("--check");
|
||
|
||
const base = existsSync(BASELINE)
|
||
? JSON.parse(await readFile(BASELINE, "utf8"))
|
||
: { creatures: {}, party: [], runs: 0, seed: 0 };
|
||
|
||
/* ---------------------------------------------------------------- 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`;
|
||
|
||
/* --- 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`);
|