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>
128 lines
5.7 KiB
JavaScript
128 lines
5.7 KiB
JavaScript
/**
|
||
* 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`);
|
||
}
|