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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-30 21:09:43 +01:00
co-authored by Claude Opus 5
parent 10f588b666
commit c3002e93c9
6 changed files with 381 additions and 10 deletions
+1 -1
View File
@@ -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"
},
+83
View File
@@ -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(/(?<![-\w])spec\.([a-zA-Z][a-zA-Z0-9]*)/g)].map(m => 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`);
+9 -4
View File
@@ -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.",
+283
View File
@@ -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;
}
+4 -4
View File
@@ -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: "<p>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.</p>"
+ "<p><em>Anchor:</em> a paperback with somebody else's name inside the cover.</p>" },
@@ -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: "<p>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.</p>"
+ "<p><em>Anchor:</em> a pager that has not worked since 2019.</p>" },
@@ -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: "<p>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.</p>"
+ "<p><em>Anchor:</em> a wind-up alarm clock that has not kept time since 2003.</p>" },
@@ -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: "<p>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.</p>"
+1 -1
View File
@@ -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.",