The forge: derive a creature, and say which rule made each number

CREATURE_FORGE_PLAN Part 3. `new` prints a spec as source in the shape the
bestiary files are written in, `explain` prints the rule behind every value,
`measure` fights it against the party check-lethality freezes, and `list`
shows the knobs.

The derivation is forge.mjs at the ROOT, beside rules.mjs and postings.mjs
and under the same law: pure, no Foundry, no catalogues. Part 5 asks for an
in-Foundry panel that forges a variant at the table and says it must not
diverge from the CLI. One derivation in a module both import is the only way
to guarantee that rather than argue it.

Natural attacks now declare which body plans can make them, in the weapon
catalogue, for the same reason a weapon already names the skill that fires
it: the weapon is the thing that knows. Anatomy cannot answer it — a body
plan records that a creature has a head, not that the head has horns.

`explain` earned itself on the first run. A large brute derived with a
baseline body could not gore, trample or hoof, and the trace said so; the
finished statblock alone would have shipped bulls that bite. The role picks
the body now, a non-baseline species overrides it, and --body beats both.

check-forge (guard 26) derives all 450 combinations of role, size, tier and
species and puts each through the bestiary's own validate(). Three mutations
were planted in the forge to test it, and it caught one. Dropping the seed
was caught. Dropping the brawl grant was a genuine no-op, since every role
already lists brawl. Dropping the body-plan filter left all 450 rows green
while the success line read "armed with something its body can use" — the
guard read the location table and never used it. It checks each issued weapon
against the catalogue's own declaration now, and the same mutation produces
500 failures.

check-rules' inline-armour pattern matched any clamp containing the word, and
false-positived three times in one evening on counts that were never damage.
damageAfterArmour is a subtraction, so the pattern requires one. A guard that
makes people contort working code to keep it quiet is training them badly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-23 00:22:39 +01:00
co-authored by Claude Opus 5
parent 5351c25fda
commit d7def0b31e
7 changed files with 505 additions and 18 deletions
+218
View File
@@ -0,0 +1,218 @@
/**
* THE FORGE — deriving a creature from a description of it.
*
* `docs/CREATURE_FORGE_PLAN.md` Part 3. It is deliberately not a form-filler: typing
* speed was never the problem, and the plan says so in as many words. It is a derivation,
* and the point of a derivation is that it can be interrogated — every number comes back
* with the rule that produced it, so a statblock can be tuned rather than re-rolled.
*
* WHY THIS FILE IS AT THE ROOT. Part 5 of the same plan asks for an in-Foundry panel that
* forges a variant at the table, and says it must not be able to diverge from the CLI.
* There is exactly one way to guarantee that rather than argue it: one derivation, in a
* module both can import. So this file lives beside rules.mjs, postings.mjs and
* anatomy.mjs, under their law — it may not import from the runtime, and it may not touch
* a Foundry global.
*
* It also may not import the catalogues. tools/content.mjs is the node side's, and the
* engine reads packs; so the caller passes `hasWeapon`, `plansFor` and the list of
* natural attacks, exactly as drawKitFor takes a classifier. A weapon this file would
* like to give a creature is given only if the caller confirms it exists AND that the
* weapon's own declaration allows this body. Nothing here contains a hand-written list
* of weapon keys that could drift out of the catalogue without anybody noticing.
*
* The output is a spec in the shape tools/bestiary-*.mjs files are written in, so it
* passes creature-schema's validate() and can be pasted into a bestiary file as source.
*/
import { SPECIES } from "./postings.mjs";
import { LOCATION_TABLES } from "./anatomy.mjs";
/**
* Size bands. SIZ is the spine of a creature: hit points come from CON and SIZ, the
* damage modifier from STR and SIZ, and build-packs cuts the token footprint at 21 and
* 30. The bands are cut at the same places, so a "huge" creature is the one that
* actually occupies three squares rather than one that merely sounds like it.
*/
export const SIZE_BANDS = {
tiny: { label: "Tiny", siz: [2, 5], str: [2, 6], con: [6, 10], note: "a cat, a hare, a bird" },
small: { label: "Small", siz: [6, 10], str: [6, 11], con: [9, 14], note: "a dog, a goose" },
medium: { label: "Medium", siz: [11, 16], str: [11, 18], con: [11, 17], note: "a person, a wolf, a boar" },
large: { label: "Large", siz: [17, 24], str: [17, 26], con: [15, 22], note: "a horse, a bull — two squares" },
huge: { label: "Huge", siz: [25, 34], str: [25, 38], con: [20, 30], note: "a whale, a cart-sized thing — three squares" }
};
/**
* What the creature does for a living, as skill emphasis and preferred natural weapons.
*
* `attacks` is an ORDER OF PREFERENCE, not a list to be issued whole. The caller's
* `hasWeapon` decides what exists and `plans` decides what the body can do — an animal
* with no hands cannot claw with them, and only a winged plan has talons. A role asking
* for something the body cannot do falls through to the next entry rather than producing
* a creature that attacks with equipment it does not have.
*/
export const CREATURE_ROLES = {
ambusher: { label: "Ambusher", blurb: "Waits, takes one thing, and is not there afterwards",
core: ["stealth", "scent", "dodge", "brawl"], support: ["spot", "listen", "athletics"],
attacks: ["bite", "claw", "grasp"], plan: "quadruped", armour: -1, stat: "dex" },
brute: { label: "Brute", blurb: "Comes straight at you and does not stop for a corridor",
core: ["brawl", "athletics"], support: ["spot", "listen", "dodge"],
attacks: ["gore", "trample", "hoof", "bite"], plan: "quadruped", armour: +2, stat: "str" },
hunter: { label: "Hunter", blurb: "Follows, wears down, and finishes at its own pace",
core: ["scent", "brawl", "athletics", "spot"], support: ["stealth", "listen", "dodge"],
attacks: ["bite", "claw", "talon"], plan: "quadruped", armour: 0, stat: "con" },
lurker: { label: "Lurker", blurb: "Holds still in water or dark until something is close",
core: ["stealth", "brawl", "listen"], support: ["dodge", "spot", "athletics"],
attacks: ["constrict", "grasp", "bite"], plan: "baseline", armour: +1, stat: "str" },
flyer: { label: "Flyer", blurb: "Takes from above and is gone before the second shot",
core: ["dodge", "spot", "athletics", "brawl"], support: ["listen", "stealth", "scent"],
attacks: ["talon", "beak", "claw"], plan: "winged", armour: -1, stat: "dex" },
sentinel: { label: "Sentinel", blurb: "Guards one place and will not be drawn off it",
core: ["spot", "listen", "brawl"], support: ["dodge", "athletics", "stealth"],
attacks: ["gore", "claw", "bite"], plan: "quadruped", armour: +3, stat: "con" }
};
/**
* Tiers. A single multiplier the GM can reason about, which is the whole design brief
* for this knob: tier 3 is the ordinary case and every band is stated relative to it.
*/
export const FORGE_TIERS = {
1: { label: "Nuisance", skill: [25, 40], armourBase: 0, chMul: 0.80 },
2: { label: "Dangerous", skill: [35, 50], armourBase: 1, chMul: 0.90 },
3: { label: "Deadly", skill: [45, 65], armourBase: 3, chMul: 1.00 },
4: { label: "Apex", skill: [60, 78], armourBase: 5, chMul: 1.15 },
5: { label: "Legend", skill: [72, 88], armourBase: 7, chMul: 1.30 }
};
/* The table that used to be here — which attacks each body plan could make — has moved
onto the weapons themselves, as `plans`. It was a second list of valid values, and a
guard written to check the forge against it would have been checking the forge against
the forge. See the note above the natural attacks in tools/content.mjs. */
/* A small deterministic generator, so a forged creature is reproducible from its seed.
Math.random would make `explain` a lie: the trace would describe numbers that could
not be produced again. */
export function forgeRng(seed = 1) {
let s = (Number(seed) || 1) >>> 0 || 1;
return () => { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
}
const clampInt = (n, lo, hi) => Math.max(lo, Math.min(hi, Math.round(n)));
/**
* Forge a creature.
*
* Returns `{ spec, trace, problems }`. `trace` is one line per derived value naming the
* rule that produced it — that is `explain`, and it is built during derivation rather
* than reconstructed afterwards, because a reconstruction is a second implementation of
* the thing it claims to describe.
*/
export function forgeCreature({
key, name, species = "baseline", bodyPlan = null, size = "medium",
role = "hunter", tier = 3, seed = 1
} = {}, { hasWeapon = () => true, plansFor = () => null, naturalAttacks = [] } = {}) {
const problems = [];
const SP = SPECIES[species];
if (!SP) problems.push(`no species "${species}" — ${Object.keys(SPECIES).join(", ")}`);
const R = CREATURE_ROLES[role];
if (!R) problems.push(`no role "${role}" — ${Object.keys(CREATURE_ROLES).join(", ")}`);
const B = SIZE_BANDS[size];
if (!B) problems.push(`no size "${size}" — ${Object.keys(SIZE_BANDS).join(", ")}`);
const T = FORGE_TIERS[Number(tier)];
if (!T) problems.push(`no tier "${tier}" — 1 to 5`);
/* The body, in order of who has the best claim to it.
*
* The species' profile came first, and every baseline species profile IS "baseline" —
* a person's shape — so a large brute was derived with no hooves and no horns and the
* trace dutifully reported that it could not gore, trample or hoof, leaving it with a
* bite. A bull is not a person who bites. The ROLE knows what shape the animal is; a
* non-baseline species knows better still, because asking for a vesh creature is
* asking for a vesh body; and --body beats both.
*
* This was visible only because `explain` prints the rejections. A forge that printed
* the finished statblock alone would have shipped bulls that bite. */
const speciesPlan = SP?.profile && SP.profile !== "baseline" ? SP.profile : null;
const plan = bodyPlan ?? speciesPlan ?? R?.plan ?? "baseline";
if (!LOCATION_TABLES[plan]) problems.push(`no body plan "${plan}" — ${Object.keys(LOCATION_TABLES).join(", ")}`);
if (problems.length) return { spec: null, trace: [], problems };
const rand = forgeRng(seed);
const trace = [];
const say = (field, value, because) => { trace.push({ field, value, because }); return value; };
const inBand = ([lo, hi]) => lo + Math.floor(rand() * (hi - lo + 1));
/* ---- characteristics ---- */
const ch = {};
ch.siz = say("siz", inBand(B.siz), `${B.label} band ${B.siz.join("–")}`);
ch.str = say("str", clampInt(inBand(B.str) * T.chMul, 1, 60),
`${B.label} band ${B.str.join("–")}, tier ${tier} ×${T.chMul}`);
ch.con = say("con", clampInt(inBand(B.con) * T.chMul, 1, 60),
`${B.label} band ${B.con.join("–")}, tier ${tier} ×${T.chMul}`);
// An animal is not stupid, it is uninterested. INT and EDU stay low and flat because
// the sheet uses EDU for training and a beast has none; the bestiary writes 1.
ch.int = say("int", inBand([3, 9]), "animal mind, 3–9");
ch.pow = say("pow", inBand([10, 18]), "10–18; POW is what makes a thing uncanny");
ch.dex = say("dex", clampInt(inBand([9, 18]) * (role === "flyer" || role === "ambusher" ? 1.25 : 1), 1, 40),
role === "flyer" || role === "ambusher" ? "9–18, ×1.25 for a fast role" : "9–18");
ch.cha = say("cha", inBand([3, 10]), "3–10");
ch.edu = say("edu", 1, "a beast has no schooling");
for (const [k, d] of Object.entries(SP.shift ?? {})) {
ch[k] = say(k, Math.max(3, (ch[k] ?? 10) + d), `${SP.label} shift ${d > 0 ? "+" : ""}${d}`);
}
/* ---- armour ---- */
const armour = say("naturalArmour", Math.max(0, T.armourBase + R.armour),
`tier ${tier} base ${T.armourBase}, ${R.label} ${R.armour >= 0 ? "+" : ""}${R.armour}`);
/* ---- natural weapons ---- */
// Preference order, filtered by what the body can do and what the catalogue has. A
// role that wants a talon on a four-legged animal gets its next choice instead.
// A weapon with no `plans` is unrestricted; one that lists them must list this body.
const canWield = w => { const p = plansFor(w); return !p || p.includes(plan); };
const weapons = [];
for (const w of R.attacks) {
if (weapons.length >= 2) break;
if (!hasWeapon(w)) { trace.push({ field: "weapons", value: `(${w} skipped)`, because: "not in the weapon catalogue" }); continue; }
if (!canWield(w)) { trace.push({ field: "weapons", value: `(${w} skipped)`, because: `the catalogue says ${w} needs ${(plansFor(w) ?? []).join(" or ")}, and this is a ${plan}` }); continue; }
weapons.push(w);
}
if (!weapons.length) {
// Never return something with nothing to do in a fight. R-213's lesson, applied at
// the point the creature is made rather than patched afterwards. The fallback is
// drawn from the same declarations, so it cannot reach for something this body
// cannot use either.
const fallback = (naturalAttacks ?? []).find(w => hasWeapon(w) && canWield(w));
if (fallback) weapons.push(fallback);
else problems.push(`no natural attack this ${plan} body can make is in the catalogue`);
}
say("weapons", weapons.join(", "), `${R.label} preference, filtered by a ${plan} body`);
/* ---- skills ---- */
const band = T.skill;
const skills = [];
const addSkill = (famSpec, range, why) => {
const [fam, spec = ""] = famSpec.split(":");
if (skills.some(s => s.fam === fam && s.spec === spec)) return;
const val = inBand(range);
skills.push({ fam, spec, val });
trace.push({ field: `skill ${famSpec}`, value: val, because: why });
};
for (const k of R.core) addSkill(k, band, `${R.label} core, tier ${tier} band ${band.join("–")}`);
const supportBand = [Math.round(band[0] * 0.7), Math.round(band[1] * 0.7)];
for (const k of R.support) addSkill(k, supportBand, `${R.label} support, 70% of the tier band`);
// Whatever it attacks with names a skill, and all ten natural attacks are brawl. Grant
// it explicitly rather than relying on the role list happening to contain it.
addSkill("brawl", band, "it attacks with its body, and every natural attack is Brawl");
const spec = {
key: key ?? `forged_${role}_${size}_${tier}`,
name: name ?? `${T.label} ${R.label.toLowerCase()}`,
role: `FORGED. ${R.blurb}, ${B.label.toLowerCase()} — ${B.note}`,
species, bodyPlan: plan,
naturalArmour: armour,
ch, skills, weapons,
tactics: `Forged at tier ${tier} (${T.label}) as a ${B.label.toLowerCase()} ${R.label.toLowerCase()}. `
+ `It fights with ${weapons.join(" and ")} and carries ${armour} points of hide. `
+ `Replace this line before the thing sees a table: a statblock without tactics is a `
+ `number, and the GM has to invent the fight anyway.`
};
return { spec, trace, problems };
}