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>
284 lines
12 KiB
JavaScript
284 lines
12 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|