Swept every reader that parses a human-written marker. Two more had R-289's fail-open. tagsIn matched a literal [CUS:, so [cus: Spot], [Cus: Spot], [CUS : Spot] and [ CUS: Spot] all read as nothing -- and it is the corpus reader behind check-scenarios' skill validation and check-rollable's reachability, so a beat spelled any of those four ways was checked by neither while both printed OK. The corpus contains no such tag today; the 88-to-89 roll count during this work was c0 writing v0.18, verified against HEAD's reader on the same tree. The POWER: marker had it with nothing covering it. bestiary.mjs and check-powers' own scan both used the literal, so a creature added with "Power:" generates no entry line and is never reported unclassified -- check-powers passes green on it, measured on a clone with its dodge skills stripped so the dodger-count assertion could not fire instead. Reader now takes POWER\s*: and stays uppercase, because power: occurs in ordinary prose; an asymmetric counter names the rest, and was measured against content.mjs first (41 strict, zero loose outside them). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
20 KiB
JavaScript
335 lines
20 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) {
|
||
/* `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 <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`);
|