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
+2 -2
View File
@@ -184,10 +184,10 @@ The build runs all twenty-four guards before it writes anything:
```
check-seam: OK — 74 modules scanned, only all-specs.mjs reads NPCS/PREGENS from content.mjs
check-generator: OK — 210 posting × tier pairs draw from their own stores, 129 weapons issued, each with the skill it names
check-forge: OK — 450 creatures derived across 6 roles × 5 sizes × 5 tiers × 3 species, every one valid, armed with something its body can use, and reproducible from its seed
check-forge: OK — 450 creatures derived across 6 roles × 5 sizes × 5 tiers × 3 species, every one valid, armed with something its body can use, and reproducible from its seed; 90 of them forged identically from the built pack and from content.mjs
check-rules: OK — 113 rules, 72 files scanned, 3 aliases + 46 constant sets checked, 417 formulas verified
check-kits: OK — 25 roles, 10 trades, 226 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 1027 keys, no leaf/branch collisions
check-lang: OK — en.json, 1044 keys, no leaf/branch collisions
check-templates: OK — 19 templates compile
check-behaviour: OK — 108 behavioural tests
check-scenarios: OK — 30 scenario files, 111 mechanic tags, every skill named resolves against 62 catalogue entries
+9 -1
View File
@@ -179,7 +179,15 @@ export function forgeCreature({
// the point the creature is made rather than patched afterwards. The fallback is
// drawn from the same declarations, so it cannot reach for something this body
// cannot use either.
const fallback = (naturalAttacks ?? []).find(w => hasWeapon(w) && canWield(w));
/* SORTED, and that is not tidiness. `naturalAttacks` arrives in whatever order the
caller's catalogue happens to hold: tools/forge.mjs reads content.mjs and gets
declaration order, while generateCreature reads the compendium and gets id order.
Taking the first match made the fallback depend on which side was asking, and the
two forged different creatures from identical requests — a tiny vesh brute got
`punch` from the CLI and `constrict` from the table. The derivation may not depend
on its caller's iteration order, so it imposes its own. */
const fallback = [...(naturalAttacks ?? [])].sort()
.find(w => hasWeapon(w) && canWield(w));
if (fallback) weapons.push(fallback);
else problems.push(`no natural attack this ${plan} body can make is in the catalogue`);
}
+17
View File
@@ -943,6 +943,23 @@
"RINGBRP.Gen.RolledChar": "{ch}",
"RINGBRP.Gen.TakeThem": "Keep these",
"RINGBRP.Gen.Nth": "agent {n}",
"RINGBRP.Forge.Title": "Forge a creature",
"RINGBRP.Forge.NewCreature": "Forge creature",
"RINGBRP.Forge.Create": "Forge",
"RINGBRP.Forge.NameHint": "left blank, it is named after what it is",
"RINGBRP.Forge.Role": "What it does",
"RINGBRP.Forge.Size": "Size",
"RINGBRP.Forge.Tier": "How bad it is",
"RINGBRP.Forge.Body": "Body",
"RINGBRP.Forge.BodyAuto": "Whatever the role and species imply",
"RINGBRP.Forge.Seed": "Seed",
"RINGBRP.Forge.SeedHint": "blank for a new one each time",
"RINGBRP.Forge.Fights": "Fights with",
"RINGBRP.Forge.Hide": "Hide",
"RINGBRP.Forge.About": "About",
"RINGBRP.Forge.AboutHint": "one roll of many; the seed fixes it",
"RINGBRP.Forge.Hint": "Every number is derived. `node tools/forge.mjs explain` with the same settings prints the rule behind each one.",
"RINGBRP.Forge.Failed": "The forge cannot make that: {why}",
"RINGBRP.Dialog.Circumstance": "How are the circumstances?",
"RINGBRP.Item.TalentId": "Talent key",
"RINGBRP.Item.TalentCategory": "Category",
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
MANIFEST-000004
MANIFEST-000002
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
MANIFEST-000002
MANIFEST-000013
Binary file not shown.
+205 -1
View File
@@ -3859,6 +3859,11 @@ import {
ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE,
PERSONAL_EFFECTS, TIERS, THREATS, SPECIES, NAME_POOLS, TRADE_BANDS, drawKitFor, CREATURE_TIER
} from "./postings.mjs";
/* The forge lives at the root under the same law as rules.mjs and postings.mjs: pure,
no Foundry. CREATURE_FORGE_PLAN Part 5 requires the panel and the CLI to be unable to
diverge, and importing the one derivation is the only way to guarantee that. */
import { forgeCreature, SIZE_BANDS, CREATURE_ROLES, FORGE_TIERS } from "./forge.mjs";
export { SIZE_BANDS, CREATURE_ROLES, FORGE_TIERS };
export {
ROLES, TRADES, CHARACTERISTIC_DICE, INDUCTION, STANDING_ISSUE, TIER_ISSUE,
@@ -4233,6 +4238,82 @@ export async function generateNPC({
return actor;
}
/**
* Forge a creature and put it on the map.
*
* The numbers are not decided here. `forgeCreature` in forge.mjs decides them, and the
* command line calls the same function with the same arguments, so a creature forged at
* the table and one forged into a bestiary file cannot disagree — which is the property
* CREATURE_FORGE_PLAN Part 5 asks for and the reason the derivation is not in this file.
*
* What is here is everything that needs Foundry: reading the catalogues out of the packs
* to tell the forge what exists, and turning the spec it returns into an actor.
*/
export async function generateCreature({
name = null, role = "hunter", size = "medium", tier = 3,
species = "baseline", bodyPlan = null, seed = null, toChat = true
} = {}) {
const weaponDocs = await game.packs.get("ringbrp.weapons").getDocuments();
const byWeaponKey = new Map();
for (const d of weaponDocs) { const k = d.flags?.ringbrp?.key; if (k) byWeaponKey.set(k, d); }
const catalogues = {
hasWeapon: k => byWeaponKey.has(k),
plansFor: k => byWeaponKey.get(k)?.flags?.ringbrp?.plans ?? null,
// Derived from the declaration rather than listed: a natural attack is exactly a
// weapon that says which bodies can make it.
naturalAttacks: [...byWeaponKey.values()]
.filter(d => Array.isArray(d.flags?.ringbrp?.plans))
.map(d => d.flags.ringbrp.key)
};
// A seed the caller did not give is still recorded on the actor, so a creature that
// turns out well at a table can be made again exactly.
const usedSeed = Number(seed) || Math.floor(Math.random() * 1e9) + 1;
const { spec, problems } = forgeCreature(
{ name, role, size, tier, species, bodyPlan, seed: usedSeed }, catalogues);
if (problems.length) {
console.error(`Custodians | forge: ${problems.join("; ")}`);
ui.notifications?.error(game.i18n.format("RINGBRP.Forge.Failed", { why: problems[0] }));
return null;
}
const finalName = name ?? spec.name;
const actor = await Actor.create({
name: finalName, type: "npc",
img: portraitFor(spec.bodyPlan, true),
prototypeToken: { name: finalName, actorLink: false, displayName: 20, disposition: -1 },
system: {
characteristics: Object.fromEntries(Object.entries(spec.ch).map(([k, v]) => [k, { value: v }])),
speciesProfile: spec.bodyPlan,
naturalArmour: { value: spec.naturalArmour },
role: spec.role
},
flags: { ringbrp: { forged: { role, size, tier, species, bodyPlan: spec.bodyPlan, seed: usedSeed } } }
});
const skillDocs = await game.packs.get("ringbrp.skills").getDocuments();
const byKey = new Map(skillDocs.map(d => [d.system.specialisationId
? `${d.system.familyId}:${d.system.specialisationId}` : d.system.familyId, d]));
const items = [];
for (const sk of spec.skills) {
const id = sk.spec ? `${sk.fam}:${sk.spec}` : sk.fam;
const doc = byKey.get(id);
if (!doc) { console.warn(`Custodians | forge: no skill item for "${id}"`); continue; }
items.push({ ...doc.toObject(), system: { ...doc.system, value: sk.val } });
}
for (const w of spec.weapons) {
const doc = byWeaponKey.get(w);
if (!doc) continue;
items.push({ ...doc.toObject(), system: { ...doc.system, equipped: true } });
}
if (items.length) await actor.createEmbeddedDocuments("Item", items);
await grantFullSkillList(actor);
if (toChat) {
await announceGenerated(actor,
`${FORGE_TIERS[tier]?.label ?? `Tier ${tier}`} · ${CREATURE_ROLES[role]?.label ?? role} · ${SIZE_BANDS[size]?.label ?? size}`);
}
return actor;
}
/**
* Everyone is ISSUED a full kit; nobody carries all of it. Anything beyond free
* carry is stowed (carried: false) heaviest-first, so a Containment agent does not
@@ -4532,8 +4613,122 @@ export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
});
}
/**
* Forge a creature, at the table.
*
* Every number comes from forge.mjs, which the command line also calls, so this panel
* cannot drift away from `node tools/forge.mjs`. What it adds is the part a CLI cannot:
* the card explains what the choice will produce BEFORE it is made, because the whole
* argument for a derivation over a form is that it can be interrogated, and a GM
* choosing between "brute" and "sentinel" from two nouns is not interrogating anything.
*/
async function forgeDialog() {
const opts = (obj, sel, label = v => v.label) => Object.entries(obj)
.map(([k, v]) => `<option value="${k}" ${k === sel ? "selected" : ""}>${label(v)}</option>`).join("");
const roleSel = pick(Object.keys(CREATURE_ROLES));
const content = `<form class="rb-dialog rb-gen">
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Name")}</label>
<input type="text" name="name" placeholder="${game.i18n.localize("RINGBRP.Forge.NameHint")}"/></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Role")}</label>
<select name="role">${opts(CREATURE_ROLES, roleSel)}</select></div>
<div class="rb-rolecard" data-rolecard></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Size")}</label>
<select name="size">${opts(SIZE_BANDS, "medium")}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Tier")}</label>
<select name="tier">${opts(FORGE_TIERS, "3", t => t.label)}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Species")}</label>
<select name="species">${opts(SPECIES, "baseline")}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Body")}</label>
<select name="body"><option value="">${game.i18n.localize("RINGBRP.Forge.BodyAuto")}</option>
${Object.keys(LOCATION_TABLES).map(k => `<option value="${k}">${k}</option>`).join("")}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Count")}</label>
<input type="number" name="count" value="1" min="1" max="12"/></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Forge.Seed")}</label>
<input type="number" name="seed" placeholder="${game.i18n.localize("RINGBRP.Forge.SeedHint")}"/></div>
<p class="notes">${game.i18n.localize("RINGBRP.Forge.Hint")}</p>
</form>`;
/* The card is derived, not written. It forges a throwaway creature at the chosen
settings and reports what came out, so the description cannot claim something the
derivation would not actually produce — the failure this repository keeps finding
in documents that describe code. */
const weaponDocs = await game.packs.get("ringbrp.weapons").getDocuments();
const byWeaponKey = new Map();
for (const d of weaponDocs) { const k = d.flags?.ringbrp?.key; if (k) byWeaponKey.set(k, d); }
const catalogues = {
hasWeapon: k => byWeaponKey.has(k),
plansFor: k => byWeaponKey.get(k)?.flags?.ringbrp?.plans ?? null,
naturalAttacks: [...byWeaponKey.values()]
.filter(d => Array.isArray(d.flags?.ringbrp?.plans)).map(d => d.flags.ringbrp.key)
};
const card = (role, size, tier, species, body) => {
const R = CREATURE_ROLES[role];
const { spec, problems } = forgeCreature(
{ role, size, tier: Number(tier), species, bodyPlan: body || null, seed: 1 }, catalogues);
if (!spec) return `<p class="rb-rolecard-blurb">${problems[0] ?? ""}</p>`;
const hp = hitPointsFor(spec.ch.con, spec.ch.siz);
const arms = spec.weapons.map(k => byWeaponKey.get(k)?.name ?? k);
return `<p class="rb-rolecard-blurb">${R?.blurb ?? ""}</p>
<dl class="rb-rolecard-facts">
<dt>${game.i18n.localize("RINGBRP.Forge.Body")}</dt><dd>${spec.bodyPlan}</dd>
<dt>${game.i18n.localize("RINGBRP.Forge.Fights")}</dt><dd>${arms.join(" · ")}</dd>
<dt>${game.i18n.localize("RINGBRP.Forge.Hide")}</dt><dd>${spec.naturalArmour}</dd>
<dt>${game.i18n.localize("RINGBRP.Forge.About")}</dt>
<dd>SIZ ${spec.ch.siz} · STR ${spec.ch.str} · ${hp} HP
<span class="rb-dim">${game.i18n.localize("RINGBRP.Forge.AboutHint")}</span></dd>
</dl>`;
};
return new Promise(resolve => {
new (rbDialogClass())({
title: game.i18n.localize("RINGBRP.Forge.Title"),
content,
render: html => {
const el = html[0] ?? html;
const holder = el.querySelector("[data-rolecard]");
if (!holder) return;
const v = n => el.querySelector(`[name=${n}]`)?.value;
const paint = () => { holder.innerHTML = card(v("role"), v("size"), v("tier"), v("species"), v("body")); };
for (const n of ["role", "size", "tier", "species", "body"]) {
el.querySelector(`[name=${n}]`)?.addEventListener("change", paint);
}
paint();
},
buttons: {
create: {
label: game.i18n.localize("RINGBRP.Forge.Create"),
callback: async html => {
const el = html[0] ?? html;
const v = n => el.querySelector(`[name=${n}]`)?.value || null;
const count = Math.max(1, Math.min(12, Number(v("count")) || 1));
const baseSeed = Number(v("seed")) || null;
const made = [];
for (let i = 0; i < count; i++) {
const name = count > 1 && v("name") ? `${v("name")} ${i + 1}` : v("name");
// Four of a kind off one statblock should not be four identical animals,
// so the seed walks. A seed the GM typed still reproduces the first one.
const seed = baseSeed ? baseSeed + i : null;
const a = await generateCreature({ name, role: v("role"), size: v("size"),
tier: Number(v("tier")), species: v("species"), bodyPlan: v("body") || null, seed });
if (a) made.push(a);
}
if (made.length === 1) made[0].sheet.render(true);
ui.notifications?.info(game.i18n.format("RINGBRP.Gen.Made", { n: made.length }));
resolve(made);
}
},
cancel: { label: game.i18n.localize("RINGBRP.Gen.Cancel"), callback: () => resolve([]) }
},
default: "create"
}).render(true);
});
}
export async function generateDialog(kind = "character") {
const isPC = kind === "character";
const isForge = kind === "creature";
const opts = (obj, sel) => Object.entries(obj).map(([k, v]) =>
`<option value="${k}" ${k === sel ? "selected" : ""}>${v.label}</option>`).join("");
const rnd = a => a[Math.floor(Math.random() * a.length)];
@@ -4542,6 +4737,13 @@ export async function generateDialog(kind = "character") {
const tierSel = "officer";
const threatSel = rnd(Object.keys(THREATS));
/* The creature path is its own form rather than more conditionals in the one below.
A forged creature shares only the name and the count with an agent: it has no
posting, no rank and no stores, and every field it does have — size, body, tier —
means nothing on the other two. Threading six more `isForge ?` ternaries through a
form that already carries `isPC ?` would have made all three harder to read. */
if (isForge) return forgeDialog();
const content = `<form class="rb-dialog rb-gen">
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Name")}</label>
<input type="text" name="name" placeholder="${game.i18n.localize("RINGBRP.Gen.NameHint")}"/></div>
@@ -4764,6 +4966,8 @@ Hooks.on("renderActorDirectory", (app, html) => {
game.i18n.localize("RINGBRP.Gen.NewAgent")}</button>
<button type="button" class="rb-genbtn" data-kind="npc">${
game.i18n.localize("RINGBRP.Gen.NewNPC")}</button>
<button type="button" class="rb-genbtn" data-kind="creature">${
game.i18n.localize("RINGBRP.Forge.NewCreature")}</button>
<button type="button" class="rb-genbtn rb-genbtn-case" data-kind="case">${
game.i18n.localize("RINGBRP.Mission.NewCase")}</button>`;
bar.querySelectorAll("button").forEach(b =>
@@ -6464,7 +6668,7 @@ Hooks.once("init", () => {
rollWeaponDamage,
restFor, endOfCase, gearBonusFor,
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
generateCharacter, generateNPC, generateCreature, generateDialog, installContent, grantFullSkillList,
ROLES, TRADES, INDUCTION, CHARACTERISTIC_DICE, rollCharacteristicsDialog,
TIERS, THREATS, SPECIES,
npcTarget, npcShouldWithdraw, declareNpcTarget,
+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`);