Files
RingBRP/tools/creature-schema.mjs
T
slaguru666andClaude Opus 5 c3002e93c9 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>
2026-08-30 21:09:43 +01:00

284 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}