Forge a creature from inside Foundry

CREATURE_FORGE_PLAN Part 5. A third button on the Actors sidebar, beside New
agent and New NPC. Pick what it does, how big it is and how bad it is; the
card underneath describes what those settings will actually produce, because
the argument for a derivation over a form is that it can be interrogated, and
choosing between "brute" and "sentinel" from two nouns is not interrogating
anything. The card is itself forged rather than written, so it cannot
describe a creature the derivation would not make.

generateCreature reads the catalogues out of the packs and calls the same
forgeCreature the CLI calls. Natural attacks carry their body-plan
declarations into flags.ringbrp.plans so the engine can read them.

The plan asks that the panel and the CLI cannot diverge, and importing one
derivation was most of that guarantee but not all of it: the two feed it from
DIFFERENT catalogues — content.mjs on one side, the built compendium on the
other. check-forge now builds the engine's view straight out of packs/weapons
and requires both to forge the same creature.

It found a divergence on its first run, in 40 of 90 pairs. The fallback took
the first usable natural attack from the caller's list, and that list arrives
in declaration order from content.mjs and in id order from the pack, so a
tiny vesh brute was punching for the CLI and constricting at the table. The
derivation may not depend on its caller's iteration order; it imposes its
own now.

Not yet verified in a running Foundry. The pure derivation is covered by 450
guarded combinations and the catalogue seam by the agreement check above, but
Actor.create, the pack reads and the dialog itself are the part
check-behaviour says outright it cannot reach. They need a world open, and
that has not happened yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-23 00:28:57 +01:00
co-authored by Claude Opus 5
parent d7def0b31e
commit 09e33907fa
22 changed files with 299 additions and 9 deletions
+6 -1
View File
@@ -102,7 +102,12 @@ function weaponItem(w) {
coherenceCost: w.coherence ?? 0
},
effects: [], ...BASE(),
flags: { ringbrp: { key: w.key, era: w.era ?? "modern" } }
/* `plans` rides in flags rather than system, because it is not a rule the sheet
applies — it is a fact about which bodies can make this attack, and the only
reader is the forge. A weapon with no declaration is unrestricted: anything that
can hold a rifle can fire it, which is a question about hands and not the rifle. */
flags: { ringbrp: { key: w.key, era: w.era ?? "modern",
...(Array.isArray(w.plans) ? { plans: w.plans } : {}) } }
};
}
+58 -2
View File
@@ -35,8 +35,12 @@ const catalogues = {
naturalAttacks: NATURAL
};
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const problems = [];
let derived = 0;
let derived = 0, agreed = 0;
for (const role of Object.keys(CREATURE_ROLES)) {
for (const size of Object.keys(SIZE_BANDS)) {
@@ -94,6 +98,57 @@ for (const role of Object.keys(CREATURE_ROLES)) {
}
}
/* ---- 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;
}
/* 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. */
{
@@ -113,4 +168,5 @@ if (problems.length) {
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`);
+ `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`);