Measuring the tier knob showed it works — one creature against the four agents check-lethality freezes, deaths per fight climb 0.01, 0.04, 0.32, 0.91, 1.43 across the five tiers for a large brute, and monotonically for hunters and flyers too. It also showed the labels oversell. "Deadly" at tier 3 kills a third of an agent and wipes the team three times in a thousand. A GM reaching for a word like that is owed the figure behind it, and the forge's whole argument is that its output can be interrogated — the tier list was the one part of it that could not be. Each tier now carries a measured `worth` line, printed by `forge list` and under the tier selector in the dialog. check-forge measures the ladder and fails if it ever goes down, across three roles and five tiers. Both the labels and the worth lines are claims about behaviour that any change to the skill bands, the hide base or the characteristic multiplier could falsify without touching a word. Proved by weakening tier 4: three roles report the inversion by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
5.8 KiB
JavaScript
131 lines
5.8 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 ${String(t.skill.join("–")).padEnd(7)} hide ${t.armourBase}`);
|
||
console.log(` ${t.worth}`);
|
||
}
|
||
} 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`);
|
||
}
|