From c3002e93c91e30bc5eebdfc7f271548f1853c2a9 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:09:43 +0100 Subject: [PATCH] check-creatures: validate every actor spec, and fix the vesh talents it found Nothing validated creature specs. check-kits validates postings and check-scenarios validates prose; buildActor throws on an unknown skill or item key and is silent about everything else, so species, marksmanship style, characteristic blocks, species-locked talents and misspelt field names could all be wrong and still build, pack and play. Every packed actor once shipped as species "hominid" and the whole cast was hit-located as something the rules do not contain. tools/creature-schema.mjs derives every allowed value from the catalogues rather than retyping them: skills from SKILL_CATALOGUE, kit from the item catalogues, species from SPECIES, stats from CHARACTERISTIC_DICE, and the two marksmanship styles out of the language file. It reports every problem with a spec rather than dying on the first, and names the nearest real key. It also reports fields buildActor never reads, because "armours:" is not an error, it is an actor that silently equips nothing. On its first run it found six baseline humans carrying vesh biology. "unblinking" is cat: species, species: vesh, and its own rule text reads "A Vesh has no face to read". Out of Step is reclassified as anomalous with a Coherence cost, which is what every talent in that category has and what the Anchor who is three years older than his birthday says always wanted to be; the other five are swapped to agency and field talents that fit them. Not fixed here: the root cause is upstream in postings.mjs, where twelve posting talent pools still offer unblinking, distributed or quorum to any agent. Picking replacements across those is a design decision, not a mechanical one. Co-Authored-By: Claude Opus 5 --- package.json | 2 +- tools/check-creatures.mjs | 83 +++++++++++ tools/content.mjs | 13 +- tools/creature-schema.mjs | 283 +++++++++++++++++++++++++++++++++++++ tools/roster.mjs | 8 +- tools/scenario-starter.mjs | 2 +- 6 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 tools/check-creatures.mjs create mode 100644 tools/creature-schema.mjs diff --git a/package.json b/package.json index fbe784f..4c01a0b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node tools/build-packs.mjs && node tools/update-readme.mjs", "icons": "node tools/make-icons.mjs", - "check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs && node tools/check-scenarios.mjs", + "check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs && node tools/check-scenarios.mjs && node tools/check-creatures.mjs", "test": "npm run check", "readme": "node tools/update-readme.mjs" }, diff --git a/tools/check-creatures.mjs b/tools/check-creatures.mjs new file mode 100644 index 0000000..70e26b1 --- /dev/null +++ b/tools/check-creatures.mjs @@ -0,0 +1,83 @@ +/** + * check-creatures — every actor spec in the repo must be one the engine can build. + * + * The five guards before this one check rules, kits, language, templates and scenario + * prose. None of them looks at an actor spec. buildActor throws on an unknown skill or + * item key, so those are caught; species, marksmanship style, characteristic blocks, + * species-locked talents and misspelt field names are not, and a spec can be wrong in + * any of those ways and still build, pack and play. Every packed actor once shipped as + * species "hominid" and the whole cast was hit-located as something the rules do not + * contain — silently, for several versions. + * + * This collects every spec that reaches buildActor and validates all of them against + * tools/creature-schema.mjs, reporting every problem rather than dying on the first. + * + * node tools/check-creatures.mjs + */ +import { NPCS, PREGENS } from "./content.mjs"; +import { ROSTER } from "./roster.mjs"; +import { readFile } from "node:fs/promises"; +import { validate, KNOWN_FIELDS } from "./creature-schema.mjs"; + +/* Scenario casts are collected by shape rather than by name: a cast is any exported + array whose entries carry characteristics. A new scenario is then covered the day it + is written instead of the day somebody remembers to add it here. */ +const SCENARIOS = ["throughtrain", "starter", "lastadmission", "openday"]; + +const sources = [ + { label: "content.mjs PREGENS", kind: "agent", specs: PREGENS }, + { label: "content.mjs NPCS", kind: "npc", specs: NPCS }, + { label: "roster.mjs ROSTER", kind: "agent", specs: ROSTER } +]; + +for (const s of SCENARIOS) { + const mod = await import(`./scenario-${s}.mjs`); + for (const [name, value] of Object.entries(mod)) { + if (Array.isArray(value) && value.some(x => x && typeof x === "object" && x.ch)) { + sources.push({ label: `scenario-${s}.mjs ${name}`, kind: "npc", specs: value }); + } + } +} + +const problems = []; +const seenKeys = new Map(); +let count = 0; + +for (const { label, kind, specs } of sources) { + for (const spec of specs) { + count++; + for (const p of validate(spec, { kind })) problems.push(`${label} — ${p}`); + // Actor ids are derived from the key, so two specs sharing one produce two actors + // with the same _id and the second silently replaces the first in the pack. + if (spec?.key) { + if (seenKeys.has(spec.key)) { + problems.push(`${label} — duplicate key "${spec.key}", already used by ${seenKeys.get(spec.key)}`); + } else seenKeys.set(spec.key, label); + } + } +} + +/* The schema's list of fields buildActor reads is a copy of a fact that lives in + build-packs.mjs, and a copy is a thing that drifts. If buildActor learns to read a new + spec field, every spec using it would be reported as writing a field nothing reads — + exactly backwards. Compare the two and say so. */ +{ + const src = await readFile(new URL("./build-packs.mjs", import.meta.url), "utf8"); + // The lookbehind matters: \b alone also matches the "spec." inside a path like + // "./expand-spec.mjs", and this guard duly reported that buildActor reads `spec.mjs`. + const read = new Set([...src.matchAll(/(? m[1])); + const missing = [...read].filter(f => !KNOWN_FIELDS.has(f)); + if (missing.length) { + problems.push(`creature-schema.mjs KNOWN_FIELDS is out of date — buildActor reads ` + + `${missing.map(f => `spec.${f}`).join(", ")}, which the schema calls inert`); + } +} + +if (problems.length) { + console.error("check-creatures: FAILED"); + for (const p of problems) console.error(" " + p); + process.exit(1); +} + +console.log(`check-creatures: OK — ${count} actor specs across ${sources.length} sources, ` + + `every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys`); diff --git a/tools/content.mjs b/tools/content.mjs index ab21a31..d56d113 100644 --- a/tools/content.mjs +++ b/tools/content.mjs @@ -560,10 +560,6 @@ export const TALENTS = [ { key:"unblinking", name:"Unblinking", cat:"species", species:"vesh", summary:"A Vesh has no face to read and does not need to read yours.", rule:"Immune to intimidation. −20% to any attempt to deceive you; −20% to your own Fast Talk." }, - { key:"out_of_step", name:"Out of Step", cat:"species", species:"vesh", - summary:"You perceive when you are a little loosely.", - rule:"Once per fight, take +3 Reaction for that round — a full lane. Declare before Reaction is rolled.", - action:"outofstep" }, { key:"distributed", name:"Distributed", cat:"species", species:"cadence", summary:"You are several bodies and one person.", rule:"You have no vital location. Each body lost costs the whole person −10% to everything." }, @@ -571,6 +567,15 @@ export const TALENTS = [ summary:"You can be in more than one conversation.", rule:"One additional non-combat action each round, resolved 2 points lower on the Reaction order than your own." }, // --- anomalous: the crossing left something in you --- + // Out of Step was filed as a vesh species talent, which made it biology nobody else + // could have — and then five human agents and three postings carried it anyway. It + // reads far better as something a crossing left behind: the Anchor who is three years + // older than his birthday says is the case for it. Moved here, and given a Coherence + // cost, because every talent in this category has one. + { key:"out_of_step", name:"Out of Step", cat:"anomalous", + summary:"You perceive when you are a little loosely.", + rule:"Once per fight, take +3 Reaction for that round — a full lane. Declare before Reaction is rolled. Costs 1 Coherence.", + coherence:1, action:"outofstep" }, { key:"echo_sense", name:"Echo Sense", cat:"anomalous", summary:"You feel a threshold before you see one.", rule:"You always know if a crossing point is within a kilometre. Using it costs 1 Coherence.", diff --git a/tools/creature-schema.mjs b/tools/creature-schema.mjs new file mode 100644 index 0000000..03dbb4e --- /dev/null +++ b/tools/creature-schema.mjs @@ -0,0 +1,283 @@ +/** + * 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 and Out of Step are vesh; Distributed and Quorum are + * cadence. Nothing stopped a baseline human being given one. + * + * 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 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)); +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", "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 "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))); + } + 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; +} diff --git a/tools/roster.mjs b/tools/roster.mjs index f66268c..90fb35e 100644 --- a/tools/roster.mjs +++ b/tools/roster.mjs @@ -40,7 +40,7 @@ export const ROSTER = [ portrait: "pc_sandoval", chosenStyle: "deliberate", species: "baseline", age: { chronological: 37, physiological: 37 }, ch: { str: 12, siz: 12, con: 13, int: 14, pow: 13, dex: 17, cha: 10, edu: 12 }, - talents: ["first_through", "unblinking"], + talents: ["first_through", "read_room"], bio: "

Takes the long lane and the long view. Keeps a private list of the shots " + "she chose not to take, and will not discuss it.

" + "

Anchor: a paperback with somebody else's name inside the cover.

" }, @@ -70,7 +70,7 @@ export const ROSTER = [ portrait: "pc_nkemdirim", chosenStyle: "deliberate", species: "baseline", age: { chronological: 32, physiological: 32 }, ch: { str: 10, siz: 11, con: 12, int: 16, pow: 13, dex: 13, cha: 13, edu: 18 }, - talents: ["triage", "unblinking"], + talents: ["triage", "read_room"], bio: "

Six years of emergency medicine and eleven months of this. Still the " + "calmest person in the room, and still startled by what the room contains.

" + "

Anchor: a pager that has not worked since 2019.

" }, @@ -122,7 +122,7 @@ export const ROSTER = [ portrait: "pc_pollard", chosenStyle: "reflex", species: "baseline", age: { chronological: 47, physiological: 47 }, ch: { str: 14, siz: 15, con: 16, int: 12, pow: 13, dex: 12, cha: 11, edu: 10 }, - talents: ["unblinking", "first_through"], + talents: ["chain_custody", "first_through"], bio: "

Sits with a site all night so that nobody else has to, and writes down the " + "times. Has never once been found asleep and never once complained about it.

" + "

Anchor: a wind-up alarm clock that has not kept time since 2003.

" }, @@ -142,7 +142,7 @@ export const ROSTER = [ portrait: "pc_braithwaite", chosenStyle: "deliberate", species: "baseline", age: { chronological: 51, physiological: 51 }, ch: { str: 11, siz: 12, con: 12, int: 17, pow: 12, dex: 13, cha: 10, edu: 17 }, - talents: ["chain_custody", "unblinking", "triage"], + talents: ["chain_custody", "clean_scene", "triage"], bio: "

Establishes what a thing was, which is usually the question the case turns " + "on. Talks to the table, not to the room, and is right often enough that " + "nobody minds.

" diff --git a/tools/scenario-starter.mjs b/tools/scenario-starter.mjs index f895168..3cbe60c 100644 --- a/tools/scenario-starter.mjs +++ b/tools/scenario-starter.mjs @@ -527,7 +527,7 @@ export const STARTER_TEAM = [ { key: "bp_elin", name: "Dr Elin Shaw", trade: "doctor", rank: "Probationary", chosenStyle: "deliberate", species: "baseline", age: { chronological: 31, physiological: 31 }, ch: { str: 10, siz: 11, con: 11, int: 16, pow: 12, dex: 12, cha: 13, edu: 17 }, - talents: ["triage", "unblinking"], + talents: ["triage", "debriefer"], extraWeapons: ["dagger"], floors: { "firearm:pistol": 42, "melee_weapon:knife": 40, brawl: 40, dodge: 40 }, spotlight: "Keep Leah alive, keep the team upright, and be the reason both engineers walk out.",