Files
RingBRP/tools/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

128 lines
5.7 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.
/**
* The forge, at the command line.
*
* The derivation is in ../forge.mjs, at the root and under the same law as rules.mjs:
* pure, no Foundry, no catalogues. This file is the part that cannot be pure — it holds
* the catalogues, validates the result against creature-schema, and formats a spec as
* source. Part 5's in-Foundry panel imports the same derivation, which is the only way
* to guarantee rather than hope that the two agree.
*
* node tools/forge.mjs new --role brute --size large --tier 4
* node tools/forge.mjs new --role flyer --body winged --seed 7 --name "The martlet"
* node tools/forge.mjs explain --role lurker --size huge --tier 5
* node tools/forge.mjs measure --role brute --size large --tier 4
* node tools/forge.mjs list
*
* `new` prints the spec as source, formatted the way tools/bestiary-*.mjs are written,
* so it can be pasted into one. It VALIDATES BEFORE PRINTING: a forge that emits
* something check-creatures would reject has wasted the reader's time and taught them to
* distrust the tool.
*
* `measure` runs it against the party check-lethality freezes, because "is this thing
* too dangerous" is the question a GM actually has, and this repository answers questions
* like that with a number rather than an adjective.
*/
import { forgeCreature, SIZE_BANDS, CREATURE_ROLES, FORGE_TIERS } from "../forge.mjs";
import { validate } from "./creature-schema.mjs";
import { WEAPONS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { measure, seedFor } from "./simulate.mjs";
const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w]));
/* The natural attacks are the ones that declare which bodies can make them; a rifle
declares nothing because holding a rifle is a question about hands, not about the
rifle. That declaration is also what makes this list derivable rather than typed. */
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 argv = process.argv.slice(2);
const cmd = argv[0] ?? "help";
const opt = (name, dflt) => {
const i = argv.indexOf(`--${name}`);
return i === -1 || !argv[i + 1] || argv[i + 1].startsWith("--") ? dflt : argv[i + 1];
};
function options() {
return {
key: opt("key", null), name: opt("name", null),
species: opt("species", "baseline"), bodyPlan: opt("body", null),
size: opt("size", "medium"), role: opt("role", "hunter"),
tier: Number(opt("tier", 3)), seed: Number(opt("seed", 1))
};
}
/** A spec as source, in the shape the bestiary files are written in. */
function asSource(spec) {
const q = s => JSON.stringify(s);
const ch = Object.entries(spec.ch).map(([k, v]) => `${k}: ${v}`).join(", ");
const skills = spec.skills
.map(s => `{fam:${q(s.fam)},spec:${q(s.spec)},val:${s.val}}`)
.join(",\n ");
return ` { key: ${q(spec.key)}, name: ${q(spec.name)}, role: ${q(spec.role)},\n`
+ ` naturalArmour: ${spec.naturalArmour}, species: ${q(spec.species)}, bodyPlan: ${q(spec.bodyPlan)},\n`
+ ` ch: { ${ch} },\n`
+ ` skills: [${skills}],\n`
+ ` weapons: [${spec.weapons.map(q).join(", ")}],\n`
+ ` tactics: ${q(spec.tactics)} },`;
}
function forgeOrDie() {
const { spec, trace, problems } = forgeCreature(options(), catalogues);
if (problems.length) {
console.error("forge: cannot derive that creature");
for (const p of problems) console.error(" " + p);
process.exit(1);
}
/* Validated before it is shown, never after. */
const bad = validate(spec);
if (bad.length) {
console.error(`forge: derived a spec that creature-schema rejects — this is a bug in `
+ `forge.mjs, not in the request`);
for (const p of bad) console.error(" " + (p.message ?? JSON.stringify(p)));
process.exit(1);
}
return { spec, trace };
}
if (cmd === "list") {
console.log("roles");
for (const [k, r] of Object.entries(CREATURE_ROLES)) console.log(` ${k.padEnd(9)} ${r.blurb}`);
console.log("\nsizes");
for (const [k, b] of Object.entries(SIZE_BANDS)) console.log(` ${k.padEnd(9)} SIZ ${String(b.siz.join("–")).padEnd(7)} ${b.note}`);
console.log("\ntiers");
for (const [k, t] of Object.entries(FORGE_TIERS)) console.log(` ${k} ${t.label.padEnd(10)} skills ${t.skill.join("–")}, hide ${t.armourBase}`);
} else if (cmd === "new") {
const { spec } = forgeOrDie();
console.log(asSource(spec));
} else if (cmd === "explain") {
const { spec, trace } = forgeOrDie();
console.log(`${spec.name} (${spec.key})`);
console.log(`${spec.role}\n`);
for (const t of trace) {
console.log(` ${String(t.field).padEnd(22)} ${String(t.value).padEnd(26)} ${t.because}`);
}
} else if (cmd === "measure") {
const { spec } = forgeOrDie();
const PARTY = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
const party = PARTY.map(k => ROSTER.find(r => r.key === k)).filter(Boolean);
const runs = Number(opt("runs", 1000));
const r = measure(party, [spec], { runs, seed: seedFor(11, spec.key) });
console.log(`${spec.name} — ${runs} runs against ${PARTY.map(k => k.slice(3)).join(", ")}`);
console.log(` party wiped ${(r.wipeRate * 100).toFixed(1)}%`);
console.log(` agents killed ${r.deathsPerRun.toFixed(2)} per fight`);
console.log(` creature killed ${(r.winRate * 100).toFixed(1)}% of fights`);
} else {
console.log(`forge — derive a creature, and say which rule produced each number
node tools/forge.mjs new --role brute --size large --tier 4
node tools/forge.mjs explain --role lurker --size huge --tier 5
node tools/forge.mjs measure --role flyer --body winged --tier 3
node tools/forge.mjs list
--role --size --tier 1-5 --species --body --seed --name --key --runs`);
}