diff --git a/README.md b/README.md index 7638d47..1d3cbb4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/forge.mjs b/forge.mjs index 5682279..39b94a2 100644 --- a/forge.mjs +++ b/forge.mjs @@ -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`); } diff --git a/lang/en.json b/lang/en.json index 5b3c5a5..40db6d2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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", diff --git a/packs/lastadmission/000003.log b/packs/lastadmission/000003.log index d904d3c..5d5f2c2 100644 Binary files a/packs/lastadmission/000003.log and b/packs/lastadmission/000003.log differ diff --git a/packs/npcs/000003.log b/packs/npcs/000003.log new file mode 100644 index 0000000..fe31464 Binary files /dev/null and b/packs/npcs/000003.log differ diff --git a/packs/npcs/000005.ldb b/packs/npcs/000005.ldb deleted file mode 100644 index b44415c..0000000 Binary files a/packs/npcs/000005.ldb and /dev/null differ diff --git a/packs/npcs/CURRENT b/packs/npcs/CURRENT index cacca75..1a84852 100644 --- a/packs/npcs/CURRENT +++ b/packs/npcs/CURRENT @@ -1 +1 @@ -MANIFEST-000004 +MANIFEST-000002 diff --git a/packs/weapons/MANIFEST-000002 b/packs/npcs/MANIFEST-000002 similarity index 100% rename from packs/weapons/MANIFEST-000002 rename to packs/npcs/MANIFEST-000002 diff --git a/packs/npcs/MANIFEST-000004 b/packs/npcs/MANIFEST-000004 deleted file mode 100644 index a463ad9..0000000 Binary files a/packs/npcs/MANIFEST-000004 and /dev/null differ diff --git a/packs/pregens/000003.log b/packs/pregens/000003.log index 4639dc5..05cd91e 100644 Binary files a/packs/pregens/000003.log and b/packs/pregens/000003.log differ diff --git a/packs/prologue/000003.log b/packs/prologue/000003.log index 3b576db..24baabe 100644 Binary files a/packs/prologue/000003.log and b/packs/prologue/000003.log differ diff --git a/packs/standingorder/000003.log b/packs/standingorder/000003.log index 48c285a..b84d601 100644 Binary files a/packs/standingorder/000003.log and b/packs/standingorder/000003.log differ diff --git a/packs/starter/000003.log b/packs/starter/000003.log index df928ff..bdaab78 100644 Binary files a/packs/starter/000003.log and b/packs/starter/000003.log differ diff --git a/packs/throughtrain/000003.log b/packs/throughtrain/000003.log index e27d6e9..d709e40 100644 Binary files a/packs/throughtrain/000003.log and b/packs/throughtrain/000003.log differ diff --git a/packs/weapons/000003.log b/packs/weapons/000003.log deleted file mode 100644 index 98c5baa..0000000 Binary files a/packs/weapons/000003.log and /dev/null differ diff --git a/packs/weapons/000005.ldb b/packs/weapons/000005.ldb new file mode 100644 index 0000000..5143d0b Binary files /dev/null and b/packs/weapons/000005.ldb differ diff --git a/packs/npcs/000006.log b/packs/weapons/000014.log similarity index 100% rename from packs/npcs/000006.log rename to packs/weapons/000014.log diff --git a/packs/weapons/CURRENT b/packs/weapons/CURRENT index 1a84852..625d147 100644 --- a/packs/weapons/CURRENT +++ b/packs/weapons/CURRENT @@ -1 +1 @@ -MANIFEST-000002 +MANIFEST-000013 diff --git a/packs/weapons/MANIFEST-000013 b/packs/weapons/MANIFEST-000013 new file mode 100644 index 0000000..c0ec63e Binary files /dev/null and b/packs/weapons/MANIFEST-000013 differ diff --git a/ringbrp.mjs b/ringbrp.mjs index 25c5501..773ff5f 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -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]) => ``).join(""); + const roleSel = pick(Object.keys(CREATURE_ROLES)); + + const content = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

${game.i18n.localize("RINGBRP.Forge.Hint")}

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

${problems[0] ?? ""}

`; + const hp = hitPointsFor(spec.ch.con, spec.ch.siz); + const arms = spec.weapons.map(k => byWeaponKey.get(k)?.name ?? k); + return `

${R?.blurb ?? ""}

+
+
${game.i18n.localize("RINGBRP.Forge.Body")}
${spec.bodyPlan}
+
${game.i18n.localize("RINGBRP.Forge.Fights")}
${arms.join(" · ")}
+
${game.i18n.localize("RINGBRP.Forge.Hide")}
${spec.naturalArmour}
+
${game.i18n.localize("RINGBRP.Forge.About")}
+
SIZ ${spec.ch.siz} · STR ${spec.ch.str} · ${hp} HP + ${game.i18n.localize("RINGBRP.Forge.AboutHint")}
+
`; + }; + + 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]) => ``).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 = `
@@ -4764,6 +4966,8 @@ Hooks.on("renderActorDirectory", (app, html) => { game.i18n.localize("RINGBRP.Gen.NewAgent")} + `; 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, diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs index 1c95a28..66f9994 100644 --- a/tools/build-packs.mjs +++ b/tools/build-packs.mjs @@ -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 } : {}) } } }; } diff --git a/tools/check-forge.mjs b/tools/check-forge.mjs index ee4c546..812caa4 100644 --- a/tools/check-forge.mjs +++ b/tools/check-forge.mjs @@ -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`);