THE DEFECT. locationFor had ONE caller in the engine, hitLocationRoll, and hitLocationRoll was called from fall, burn, detonate and burstAttack. Falling, fire, explosions and automatic fire. Nothing else. rollWeaponDamage rolled dice, printed a number and returned it, so the chat Damage button, the character sheet's damage button and the item sheet's all stopped there. A grenade knew which arm it took off. A sword did not. Every individual piece was already correct - the tables, per-location armour and coverage, disable and destroy thresholds, Major Wound, dying, death, the figure on the sheet. They simply were not joined up, which is why this reads as a wiring change rather than a new subsystem. - rollWeaponDamage now carries through to hitLocationRoll: it rolls the location, applies that location's armour, applies the wound, and handles the rest through the machinery that already owned it. Nothing here re-implements any of that - locationModeFor picks the melee or ranged column from the weapon's class. The two columns have always differed and nothing was choosing between them - with no target it does exactly what it did before and SAYS SO on a card, rather than failing quietly - burstAttack opts out with locate:false - it rolls its own location per round, and letting both fire would have applied every round of a burst twice BODY PLANS. Two new tables: quadruped (four legs, fore/hindquarters, neck, a head that is hard to reach) and winged (that, plus wings). Forequarters are the vital. DISABLE EITHER WING AND IT CANNOT FLY, which is the fight a party can win. Barghest, Kelpie and Church grim were all species "baseline" - a black dog, a horse and a grave-hound, each hit-located with two arms and a chest. Fixed. Added "The supporter", a winged heraldic beast, so the new table is exercised by real content instead of existing unused. Body plan is a NEW FIELD, not a species. A bear is not a playable people with characteristic dice and talents, so creature specs take bodyPlan and the playable species list is untouched. THE NPC SHEET had no hit locations at all - 102 lines against the character sheet's 627 - while every NPC in the game had them derived and wired to consequences. It now carries the body-plan selector, the figure, the per-location table, the condition and a GROUNDED flag. The sheet class already extended the character sheet, so it already HAD the context; the template never used it. FOUND IN PASSING, by the new guard: kind "body" - the abdomen, the trunk, the hindquarters - did nothing whatever. Its printed effect has always promised "-30% to all Physical actions and bleeding 1 hit point per round until First Aid" and the code delivered none of it, on every body plan including the humanoid default. It now bleeds 1, or 2 destroyed, exactly as bodyX says. NEW GUARD: check-anatomy. Every body plan must cover 1-20 exactly once in BOTH modes, every location must be drawn and every drawn shape must be a location, and every kind must be one locationEffectsFor acts on, has effect text for, and has a destruction outcome. Verified it fails on a d20 gap, a shapeless limb and an unhandled kind before wiring it in. Ninth guard. VERIFIED IN A RUNNING FOUNDRY, not just unit-tested: melee bar reads the Melee column and the rifle reads Ranged on the same creature; forequarters graded as a vital hit; a maxed wing set flightLost and the sheet said GROUNDED; the real chat Damage button rolled 1D20 15, found the right foreleg, applied 12 through armour, disabled the leg and took the dog from 16 to 4; and with nothing targeted nothing was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
11 KiB
JavaScript
212 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.",
|
||
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 at seed\n`
|
||
+ `${base.seed} 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`);
|