/** * The shape of a creature, and the only place that decides whether one is valid. * * Every actor in this game — pregen, roster agent, NPC, monster — reaches buildActor as * a plain object literal hand-written in content.mjs, roster.mjs or a scenario file. * Nothing checked those objects. buildActor throws on an unknown skill, weapon, armour, * gear or talent key, which catches the loud half; it is silent about everything else. * * The silent half is the reason this exists: * * SPECIES. Every packed actor once shipped as species "hominid" — a Ringworld name * this game does not define — and the sheet falls back to baseline on an unknown key, * so the entire cast was drawn and hit-located as something the rules do not contain. * Nothing failed. See the note in build-packs.mjs where it is now written explicitly. * * MISSPELT FIELDS. buildActor reads spec.armour. A spec written with `armours:` is * not an error, it is an actor that silently equips nothing, and there is no way to * tell that apart from a creature that was meant to be unarmoured. * * CHOSENSTYLE. styleFor() is `chosen => chosen`, a passthrough, so any string at all * becomes a marksmanship style and the sheet renders whatever it is handed. * * SPECIES TALENTS. Unblinking is vesh; Distributed and Quorum are cadence. Nothing * stopped a baseline human being given one. * * Out of Step is NOT one of them, though SPECIES.vesh lists it. Two different ideas * share that field: SPECIES[x].talents is what a generated member of the species is * GRANTED, while TALENTS[].species is what only that species may HAVE. Out of Step is * an anomalous talent — five postings and a roster agent offer it to humans — that a * Vesh happens to get for free. Locking it to vesh would take it off all six of them. * * Everything allowed here is DERIVED from the catalogues rather than retyped, for the * reason check-kits gives: a hand-written list of valid values drifts, and the drift is * invisible until something rolls 5% with a weapon it should be trained in. Skills come * from SKILL_CATALOGUE, kit from the item catalogues, species from SPECIES, stats from * CHARACTERISTIC_DICE, and the marksmanship styles from the language file — which * check-lang already guarantees is complete. * * Used by tools/check-creatures.mjs. Kept free of Foundry globals and of node APIs so * it stays importable from either side. */ import { SKILL_CATALOGUE, TALENTS, GEAR, WEAPONS, ARMOURS, DEVICES } from "./content.mjs"; import { SPECIES, CHARACTERISTIC_DICE } from "../postings.mjs"; import { LOCATION_TABLES } from "../anatomy.mjs"; import EN from "../lang/en.json" with { type: "json" }; /* ---------------------------------------------------------------- vocabularies */ /* Always "family:spec", with an empty spec for unspecialised skills, because that is the key buildActor looks up: SKILL_LOOKUP.get(`${s.fam}:${s.spec}`). check-kits builds the same vocabulary the other way — bare family when there is no spec — because it is matching posting skill lists, which are written that way. Two conventions genuinely coexist in this repo; a creature spec uses this one. */ export const SKILLS = new Set(SKILL_CATALOGUE.map(r => `${r[0]}:${r[1] ?? ""}`)); export const WEAPON_KEYS = new Set(WEAPONS.map(w => w.key)); export const ARMOUR_KEYS = new Set(ARMOURS.map(a => a.key)); export const GEAR_KEYS = new Set(GEAR.map(g => g.key)); export const DEVICE_KEYS = new Set((DEVICES ?? []).map(d => d.key)); export const TALENT_KEYS = new Set(TALENTS.map(t => t.key)); export const SPECIES_KEYS = new Set(Object.keys(SPECIES)); /* Which BODY a creature is hit-located as. Separate from species on purpose: species is a playable people with characteristic dice and talents, and a bear is not one. Without this a four-legged thing could only be authored as a baseline humanoid, so every hound and horse in the bestiary had two arms and a chest. */ export const BODY_PLAN_KEYS = new Set(Object.keys(LOCATION_TABLES)); export const STATS = Object.keys(CHARACTERISTIC_DICE); /** Which species a species-locked talent belongs to; absent means anyone may take it. */ export const TALENT_SPECIES = new Map( TALENTS.filter(t => t.species).map(t => [t.key, t.species]) ); /* The two marksmanship styles, read out of the language file rather than written down again here. RINGBRP.Style.Reflex and .Deliberate are the only two that exist, and check-lang fails the build if either goes missing, so this cannot quietly empty. */ export const STYLES = new Set( Object.keys(EN) .filter(k => /^RINGBRP\.Style\.[A-Z]/.test(k)) .map(k => k.split(".").pop().toLowerCase()) ); /** * Fields buildActor reads. Anything else on a spec is inert — it was either a typo for * one of these or a note to the author, and both are worth saying out loud. * Kept in sync with build-packs.mjs by check-creatures, which compares the two. */ export const KNOWN_FIELDS = new Set([ "key", "name", "role", "ch", "skills", "species", "bodyPlan", "chosenStyle", "naturalArmour", "weapons", "extraWeapons", "armour", "gear", "extraGear", "devices", "talents", "tactics", "note", "bio", "portrait", "rank", "trade", "age", "floors", "spotlight", "environments", "complication", "formula", "results" ]); /* ---------------------------------------------------------------- suggestions */ function editDistance(a, b) { const m = a.length, n = b.length; let prev = Array.from({ length: n + 1 }, (_, j) => j); for (let i = 1; i <= m; i++) { const cur = [i]; for (let j = 1; j <= n; j++) { cur[j] = Math.min( prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) ); } prev = cur; } return prev[n]; } /** * The closest candidate, if one is close enough to be worth naming. A wrong suggestion * is worse than none — it sends the reader to check something that was never the * problem — so the threshold is deliberately tight. */ export function nearest(value, candidates) { const v = String(value).toLowerCase(); let best = null, bestD = Infinity; for (const c of candidates) { const d = editDistance(v, String(c).toLowerCase()); if (d < bestD) { bestD = d; best = c; } } const limit = Math.max(2, Math.floor(v.length * 0.34)); return bestD <= limit ? best : null; } /** What a field will accept, for error text and for the forge CLI later. */ export function describe(field) { switch (field) { case "species": return [...SPECIES_KEYS]; case "bodyPlan": return [...BODY_PLAN_KEYS]; case "chosenStyle": return [...STYLES]; case "skills": return [...SKILLS]; case "weapons": case "extraWeapons": return [...WEAPON_KEYS]; case "armour": return [...ARMOUR_KEYS]; case "gear": case "extraGear": return [...GEAR_KEYS]; case "devices": return [...DEVICE_KEYS]; case "talents": return [...TALENT_KEYS]; case "ch": return STATS; default: return null; } } /* ---------------------------------------------------------------- validation */ const problem = (path, msg, suggestion) => ({ path, problem: msg, nearest: suggestion ?? null, toString() { return `${path}: ${msg}${suggestion ? ` — did you mean "${suggestion}"?` : ""}`; } }); /** A key list field: every entry must resolve against `set`. */ function checkKeys(out, spec, field, set) { const raw = spec[field]; if (raw === undefined) return; if (!Array.isArray(raw)) { out.push(problem(`${spec.key}.${field}`, `must be an array, got ${typeof raw}`)); return; } raw.forEach((entry, i) => { // gear may be written as [key, quantity] const key = Array.isArray(entry) ? entry[0] : entry; if (typeof key !== "string") { out.push(problem(`${spec.key}.${field}[${i}]`, `is ${typeof key}, expected a key`)); return; } if (!set.has(key)) { out.push(problem(`${spec.key}.${field}[${i}]`, `no such entry "${key}"`, nearest(key, set))); } }); } /** * Every problem with one spec. Returns them ALL rather than throwing on the first, * because buildActor already throws on the first and that is precisely what makes * fixing a batch of creatures a one-at-a-time slog. */ export function validate(spec) { const out = []; const where = spec?.key ?? "(anonymous)"; if (!spec || typeof spec !== "object") { return [problem("(spec)", "is not an object")]; } if (typeof spec.key !== "string" || !spec.key.trim()) { out.push(problem("(spec)", "has no key")); } if (typeof spec.name !== "string" || !spec.name.trim()) { out.push(problem(`${where}.name`, "has no name")); } /* --- characteristics --- */ if (!spec.ch || typeof spec.ch !== "object") { out.push(problem(`${where}.ch`, "has no characteristics")); } else { for (const stat of STATS) { const v = spec.ch[stat]; if (v === undefined) { out.push(problem(`${where}.ch.${stat}`, "is missing")); continue; } if (!Number.isInteger(v)) { out.push(problem(`${where}.ch.${stat}`, `is ${JSON.stringify(v)}, expected a whole number`)); } else if (v < 1 || v > 60) { // 60 is far above anything the tiers produce; this is a typo net, not a balance rule. out.push(problem(`${where}.ch.${stat}`, `is ${v}, outside 1–60`)); } } for (const k of Object.keys(spec.ch)) { if (!STATS.includes(k)) { out.push(problem(`${where}.ch.${k}`, "is not a characteristic", nearest(k, STATS))); } } } /* --- species, and the talents locked to it --- */ if (spec.species !== undefined && !SPECIES_KEYS.has(spec.species)) { out.push(problem(`${where}.species`, `no such species "${spec.species}"`, nearest(spec.species, SPECIES_KEYS))); } if (spec.bodyPlan !== undefined && !BODY_PLAN_KEYS.has(spec.bodyPlan)) { out.push(problem(`${where}.bodyPlan`, `no such body plan "${spec.bodyPlan}"`, nearest(spec.bodyPlan, BODY_PLAN_KEYS))); } const species = spec.species ?? "baseline"; for (const t of spec.talents ?? []) { const locked = TALENT_SPECIES.get(t); if (locked && locked !== species) { out.push(problem(`${where}.talents`, `talent "${t}" is ${locked}-only, but this is ${species}`)); } } /* --- marksmanship style --- */ if (spec.chosenStyle !== undefined && !STYLES.has(spec.chosenStyle)) { out.push(problem(`${where}.chosenStyle`, `no such style "${spec.chosenStyle}"`, nearest(spec.chosenStyle, STYLES))); } /* --- skills --- */ if (spec.skills !== undefined) { if (!Array.isArray(spec.skills)) { out.push(problem(`${where}.skills`, `must be an array, got ${typeof spec.skills}`)); } else { const seen = new Set(); spec.skills.forEach((s, i) => { if (!s || typeof s !== "object") { out.push(problem(`${where}.skills[${i}]`, "is not a skill entry")); return; } const id = `${s.fam}:${s.spec ?? ""}`; // The lookup key always carries the colon; a reader should not have to. An // unspecialised skill is "stealth", not "stealth:". const shown = id.endsWith(":") ? id.slice(0, -1) : id; if (!SKILLS.has(id)) { const near = nearest(id, SKILLS); out.push(problem(`${where}.skills[${i}]`, `no such skill "${shown}"`, near ? (near.endsWith(":") ? near.slice(0, -1) : near) : null)); } if (seen.has(id)) { // buildActor pushes both, and the sheet shows the skill twice at two values. out.push(problem(`${where}.skills[${i}]`, `"${shown}" is listed twice`)); } seen.add(id); if (!Number.isInteger(s.val)) { out.push(problem(`${where}.skills[${i}]`, `"${shown}" value is ${JSON.stringify(s.val)}, expected a whole number`)); } else if (s.val < 1 || s.val > 100) { out.push(problem(`${where}.skills[${i}]`, `"${shown}" is ${s.val}%, outside 1–100`)); } }); } } /* --- kit --- */ checkKeys(out, spec, "weapons", WEAPON_KEYS); checkKeys(out, spec, "extraWeapons", WEAPON_KEYS); checkKeys(out, spec, "armour", ARMOUR_KEYS); checkKeys(out, spec, "gear", GEAR_KEYS); checkKeys(out, spec, "extraGear", GEAR_KEYS); checkKeys(out, spec, "devices", DEVICE_KEYS); checkKeys(out, spec, "talents", TALENT_KEYS); /* --- natural armour --- */ if (spec.naturalArmour !== undefined) { const n = Number(spec.naturalArmour); if (!Number.isFinite(n) || n < 0) { out.push(problem(`${where}.naturalArmour`, `is ${JSON.stringify(spec.naturalArmour)}, expected 0 or more`)); } } /* --- fields nothing reads --- */ for (const k of Object.keys(spec)) { if (!KNOWN_FIELDS.has(k)) { out.push(problem(`${where}.${k}`, "is not a field buildActor reads — it does nothing", nearest(k, KNOWN_FIELDS))); } } return out; }