/** * 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) { /* `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 }; } 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 --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`);