vesh and cadence have had complete hit-location tables, body geometry, species talents and location effects since they were written, and no creature had ever used either. So none of that had run outside the character generator, and the whole bestiary was hit-located as a person - including a black dog and a stack of paper. THE CHOIR IS A CADENCE. It always was: its tactics say "run them as ELEVEN separate bodies with one initiative between them", which is Distributed and Quorum described in prose because the species that models it had no users. Retagged, and given the two talents. No scenario prose refers to it, so nothing else moves. PRECEDENT is new, and is a Vesh - the first. Built to exercise what makes the species different rather than to be another sack of hit points: no head, so no fight against one ends on a lucky roll, and a sensory ridge for a vital that runs the length of its back and is correspondingly easy to hit. It is also not hostile, which is the point; the encounter is a conversation with something that answers questions about an event that has not happened yet. Two things left deliberately unfixed and written down rather than fudged: - The choir is eleven in the fiction and five in the location table. Reconciling it properly means giving cadence a per-creature body count, which stops the table being a constant and makes the paper doll follow it. That is the right fix and it is not a small one. A note on the spec says so. - Precedent gets unblinking but not out_of_step, though SPECIES.vesh grants both. An npc actor has no Coherence track, and out_of_step costs 1 to use. Also corrects a comment in creature-schema.mjs claiming Out of Step is vesh-locked. It is not - five postings and a roster agent offer it to humans, and locking it would take it off all six. SPECIES[x].talents is what a species is GRANTED; TALENTS[].species is what only that species may HAVE. Two different fields, and only the second locks. Simulated: Precedent 0.68 of 4 hurt over 3 rounds, no wipes. The choir alone is trivial; at the eleven its own tactics call for, 3.05 of 4 hurt, median 18 rounds, 0.3% wipes. Read both as floors - the harness does not model hit locations, so a Cadence's entire mechanical identity is exactly what it cannot yet measure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
12 KiB
JavaScript
290 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 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 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;
|
||
}
|