check-bestiary proves the page matches its generator, and the generator had never heard of powers.mjs -- so the redcap sat in "the ones that take the most stripping" under eighteen green guards while its own entry said it never spends a defence. Two files agreeing with each other while both disagree with the engine is a quorum, not a check. check-powers now asserts per effect kind what the page must say: defenceStacking requires the creature off the stripping list, named as exempt, and the "across N creatures that spend defences" count reconciled against powers.mjs; attackFactor requires the rating the simulator actually uses printed as a number, which the courier's entry now carries. The clause that matters is the failure on an unknown effect kind -- a wired effect with no DOCUMENT_RULE fails the build, so the next one cannot arrive without somebody deciding what the document owes it. Without that this would guard the mistake already made and nothing else. Proved three ways in a worktree, exit codes read directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
19 KiB
JavaScript
332 lines
19 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 { 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", "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} 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 --- */
|
||
|
||
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`;
|
||
/* 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 => r.tag === t)).length} groupings, `
|
||
+ `${rows.filter(r => r.measured).length} with measured lethality`);
|