Files
RingBRP/tools/check-forge.mjs
T
slaguru666andClaude Opus 5 d7def0b31e 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>
2026-09-23 00:22:39 +01:00

117 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Every creature the forge offers to make must be one the game accepts.
*
* The forge takes four knobs — role, size, tier, species — and a GM will reach for the
* combination nobody tried. A derivation that produces an invalid spec at one corner of
* that space is not a tool, it is a trap, and the corner it fails at is by definition
* the one nobody looked at.
*
* So all of it is derived here, every role × size × tier × species, and each result is
* put through the same validate() that guards the written bestiary. Four further things
* are asserted that validate() has no opinion about:
*
* - it can fight. A creature with no natural attack is the R-213 defect, made new
* rather than inherited.
* - what it fights with, its body can do. A bull that bites because the derivation
* silently fell back is a real bug this guard found on its first run.
* - the same seed gives the same creature. `explain` claims to name the rule behind
* each number; if the numbers are not reproducible that claim is a fiction.
* - every weapon and skill it names resolves against the catalogues.
*
* bun tools/check-forge.mjs
*/
import { forgeCreature, SIZE_BANDS, CREATURE_ROLES, FORGE_TIERS } from "../forge.mjs";
import { validate } from "./creature-schema.mjs";
import { WEAPONS, SKILL_CATALOGUE } from "./content.mjs";
import { LOCATION_TABLES } from "../anatomy.mjs";
import { SPECIES } from "../postings.mjs";
const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w]));
const SKILLS = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0])));
const NATURAL = WEAPONS.filter(w => Array.isArray(w.plans)).map(w => w.key);
const catalogues = {
hasWeapon: k => WEAPON_BY_KEY.has(k),
plansFor: k => WEAPON_BY_KEY.get(k)?.plans ?? null,
naturalAttacks: NATURAL
};
const problems = [];
let derived = 0;
for (const role of Object.keys(CREATURE_ROLES)) {
for (const size of Object.keys(SIZE_BANDS)) {
for (const tier of Object.keys(FORGE_TIERS)) {
for (const species of Object.keys(SPECIES)) {
const where = `${role}/${size}/t${tier}/${species}`;
const req = { role, size, tier: Number(tier), species, seed: 5, key: `probe_${role}_${size}_${tier}_${species}` };
const { spec, trace, problems: derivProblems } = forgeCreature(req, catalogues);
if (derivProblems.length) { problems.push(`${where}: ${derivProblems.join("; ")}`); continue; }
derived++;
for (const p of validate(spec)) {
problems.push(`${where}: ${p.message ?? JSON.stringify(p)}`);
}
if (!spec.weapons.length) {
problems.push(`${where}: no natural attack — it would stand in the fight with nothing to do`);
}
// What it fights with, the body must be able to do — checked against the
// WEAPON's own declaration, not against any table the forge also reads for its
// filtering. The first version of this guard read the location table here and
// then never used it, while the success line claimed the check had been made:
// deleting the forge's body filter entirely left all 450 rows green.
if (!LOCATION_TABLES[spec.bodyPlan]) {
problems.push(`${where}: body plan "${spec.bodyPlan}" has no hit-location table`);
}
for (const w of spec.weapons) {
const plans = WEAPON_BY_KEY.get(w)?.plans;
if (Array.isArray(plans) && !plans.includes(spec.bodyPlan)) {
problems.push(`${where}: issued "${w}", which the catalogue says only a `
+ `${plans.join(" or ")} can make, to a ${spec.bodyPlan}`);
}
}
for (const w of spec.weapons) {
if (!WEAPON_BY_KEY.has(w)) { problems.push(`${where}: weapon "${w}" is in no catalogue`); continue; }
const fam = WEAPON_BY_KEY.get(w).fam;
const spc = WEAPON_BY_KEY.get(w).spec;
const skillKey = spc ? `${fam}:${spc}` : fam;
if (!spec.skills.some(s => (s.spec ? `${s.fam}:${s.spec}` : s.fam) === skillKey)) {
problems.push(`${where}: carries "${w}", which is fired by ${skillKey}, and has no such skill`);
}
}
for (const s of spec.skills) {
const k = s.spec ? `${s.fam}:${s.spec}` : s.fam;
if (!SKILLS.has(k)) problems.push(`${where}: skill "${k}" is not in the catalogue`);
if (!Number.isFinite(s.val) || s.val < 1) problems.push(`${where}: skill "${k}" has value ${s.val}`);
}
// Reproducible, or `explain` describes numbers that cannot be made again.
const again = forgeCreature(req, catalogues).spec;
if (JSON.stringify(again) !== JSON.stringify(spec)) {
problems.push(`${where}: the same seed produced a different creature — explain() is describing numbers it cannot reproduce`);
}
if (!trace.length) problems.push(`${where}: derived with an empty trace, so nothing can be explained`);
}
}
}
}
/* A different seed must give a different creature, or the seed is decorative and every
forged creature at a tier is the same one wearing a different name. */
{
const a = forgeCreature({ role: "hunter", size: "medium", tier: 3, seed: 1 }, catalogues).spec;
const b = forgeCreature({ role: "hunter", size: "medium", tier: 3, seed: 99 }, catalogues).spec;
if (JSON.stringify(a.ch) === JSON.stringify(b.ch)) {
problems.push(`seeds 1 and 99 produced identical characteristics — the seed does nothing`);
}
}
if (problems.length) {
console.error(`check-forge: FAILED — ${problems.length} problem(s)`);
for (const p of problems.slice(0, 30)) console.error(" " + p);
if (problems.length > 30) console.error(` … and ${problems.length - 30} more`);
process.exit(1);
}
console.log(`check-forge: OK — ${derived} creatures derived across `
+ `${Object.keys(CREATURE_ROLES).length} roles × ${Object.keys(SIZE_BANDS).length} sizes × `
+ `${Object.keys(FORGE_TIERS).length} tiers × ${Object.keys(SPECIES).length} species, `
+ `every one valid, armed with something its body can use, and reproducible from its seed`);