/** * 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`); }