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>
219 lines
11 KiB
JavaScript
219 lines
11 KiB
JavaScript
/**
|
||
* 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 { ROSTER } from "./roster.mjs";
|
||
import { measure, seedFor } from "./simulate.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
|
||
};
|
||
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
|
||
const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
|
||
const LADDER_RUNS = 400;
|
||
|
||
const problems = [];
|
||
let derived = 0, agreed = 0, ladder = 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`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---- the panel and the CLI must not be able to diverge ----
|
||
*
|
||
* CREATURE_FORGE_PLAN Part 5 asks for an in-Foundry panel that cannot drift away from the
|
||
* command line, and the answer was to put the derivation in one module both import. That
|
||
* is most of the guarantee, and it is not all of it: the two feed that derivation from
|
||
* DIFFERENT catalogues. tools/forge.mjs reads content.mjs; generateCreature reads the
|
||
* built compendium, where `plans` rides in flags.ringbrp and is written by build-packs.
|
||
*
|
||
* So a declaration could be added to content.mjs, never reach the pack, and the table
|
||
* would quietly forge different creatures from the ones the CLI prints — with both sides
|
||
* still importing the same function and the plan's guarantee still apparently intact.
|
||
* This reads the built pack exactly as the engine does and requires the two to agree. */
|
||
{
|
||
const { ClassicLevel } = await import("classic-level");
|
||
const db = new ClassicLevel(path.join(ROOT, "packs", "weapons"), { valueEncoding: "json" });
|
||
await db.open();
|
||
const packed = new Map();
|
||
for await (const [, v] of db.iterator()) {
|
||
const k = v?.flags?.ringbrp?.key;
|
||
if (k) packed.set(k, v.flags.ringbrp.plans ?? null);
|
||
}
|
||
await db.close();
|
||
|
||
const fromPack = {
|
||
hasWeapon: k => packed.has(k),
|
||
plansFor: k => packed.get(k) ?? null,
|
||
naturalAttacks: [...packed].filter(([, p]) => Array.isArray(p)).map(([k]) => k)
|
||
};
|
||
if (!fromPack.naturalAttacks.length) {
|
||
problems.push(`the built weapons pack declares no body plans at all — build-packs is `
|
||
+ `dropping flags.ringbrp.plans, and the table would forge from an empty rule`);
|
||
}
|
||
let compared = 0;
|
||
for (const role of Object.keys(CREATURE_ROLES)) {
|
||
for (const size of Object.keys(SIZE_BANDS)) {
|
||
for (const species of Object.keys(SPECIES)) {
|
||
const req = { role, size, tier: 3, species, seed: 5, key: `agree_${role}_${size}_${species}` };
|
||
const viaSource = forgeCreature(req, catalogues).spec;
|
||
const viaPack = forgeCreature(req, fromPack).spec;
|
||
compared++;
|
||
if (JSON.stringify(viaSource) !== JSON.stringify(viaPack)) {
|
||
problems.push(`${role}/${size}/${species}: the CLI's catalogue and the built pack `
|
||
+ `forge different creatures — weapons ${viaSource?.weapons?.join(",")} vs `
|
||
+ `${viaPack?.weapons?.join(",")}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!problems.length) agreed = compared;
|
||
}
|
||
|
||
/* ---- the tier ladder must climb ----
|
||
*
|
||
* "A --tier bump is a single multiplier a GM can reason about" is the design brief for
|
||
* that knob, and FORGE_TIERS now prints what each tier is worth in plain words. Both are
|
||
* claims about measured behaviour, and neither is worth anything if tier 4 can quietly
|
||
* become less dangerous than tier 3 — which any change to the skill bands, the hide base
|
||
* or the characteristic multiplier could do without touching a label.
|
||
*
|
||
* Measured, not reasoned about. Fewer runs than check-lethality because the question is
|
||
* direction rather than an exact figure, and deterministic for the same reason that guard
|
||
* takes no tolerance: same seed, same party, same numbers. */
|
||
{
|
||
const party = PARTY_KEYS.map(k => ROSTER.find(r => r.key === k)).filter(Boolean);
|
||
if (party.length !== PARTY_KEYS.length) {
|
||
problems.push(`the frozen party names an agent not on the roster`);
|
||
} else {
|
||
for (const role of ["brute", "hunter", "flyer"]) {
|
||
let prev = null;
|
||
for (const tier of [1, 2, 3, 4, 5]) {
|
||
const spec = forgeCreature({ role, size: "large", tier, seed: 4, key: `ladder_${role}_${tier}` }, catalogues).spec;
|
||
const r = measure(party, [spec], { runs: LADDER_RUNS, seed: seedFor(11, spec.key) });
|
||
const worse = r.deathsPerRun;
|
||
if (prev !== null && worse < prev.worth) {
|
||
problems.push(`${role}: tier ${tier} kills ${worse.toFixed(2)} agents per fight and `
|
||
+ `tier ${prev.tier} kills ${prev.worth.toFixed(2)} — the ladder goes down, so the `
|
||
+ `tier labels and their "worth" lines are describing something else`);
|
||
}
|
||
prev = { tier, worth: worse };
|
||
ladder++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Every tier must say what it is worth, because the dialog prints that line and an empty
|
||
one leaves a GM choosing between five adjectives. */
|
||
for (const [k, t] of Object.entries(FORGE_TIERS)) {
|
||
if (!t.worth || !t.worth.trim()) problems.push(`FORGE_TIERS.${k} has no "worth" line`);
|
||
}
|
||
|
||
/* 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; `
|
||
+ `${agreed} of them forged identically from the built pack and from content.mjs; `
|
||
+ `the tier ladder climbs across ${ladder} measured fights`);
|