Files
RingBRP/tools/creature-schema.mjs
T
slaguru666andClaude Opus 5 ddc4f99ff4 Hit locations reach ordinary combat, and creatures get a body
THE DEFECT. locationFor had ONE caller in the engine, hitLocationRoll, and
hitLocationRoll was called from fall, burn, detonate and burstAttack. Falling,
fire, explosions and automatic fire. Nothing else. rollWeaponDamage rolled dice,
printed a number and returned it, so the chat Damage button, the character
sheet's damage button and the item sheet's all stopped there.

A grenade knew which arm it took off. A sword did not.

Every individual piece was already correct - the tables, per-location armour and
coverage, disable and destroy thresholds, Major Wound, dying, death, the figure
on the sheet. They simply were not joined up, which is why this reads as a wiring
change rather than a new subsystem.

- rollWeaponDamage now carries through to hitLocationRoll: it rolls the location,
  applies that location's armour, applies the wound, and handles the rest through
  the machinery that already owned it. Nothing here re-implements any of that
- locationModeFor picks the melee or ranged column from the weapon's class. The
  two columns have always differed and nothing was choosing between them
- with no target it does exactly what it did before and SAYS SO on a card, rather
  than failing quietly
- burstAttack opts out with locate:false - it rolls its own location per round,
  and letting both fire would have applied every round of a burst twice

BODY PLANS. Two new tables: quadruped (four legs, fore/hindquarters, neck, a head
that is hard to reach) and winged (that, plus wings). Forequarters are the vital.
DISABLE EITHER WING AND IT CANNOT FLY, which is the fight a party can win.
Barghest, Kelpie and Church grim were all species "baseline" - a black dog, a
horse and a grave-hound, each hit-located with two arms and a chest. Fixed. Added
"The supporter", a winged heraldic beast, so the new table is exercised by real
content instead of existing unused.

Body plan is a NEW FIELD, not a species. A bear is not a playable people with
characteristic dice and talents, so creature specs take bodyPlan and the playable
species list is untouched.

THE NPC SHEET had no hit locations at all - 102 lines against the character
sheet's 627 - while every NPC in the game had them derived and wired to
consequences. It now carries the body-plan selector, the figure, the per-location
table, the condition and a GROUNDED flag. The sheet class already extended the
character sheet, so it already HAD the context; the template never used it.

FOUND IN PASSING, by the new guard: kind "body" - the abdomen, the trunk, the
hindquarters - did nothing whatever. Its printed effect has always promised "-30%
to all Physical actions and bleeding 1 hit point per round until First Aid" and
the code delivered none of it, on every body plan including the humanoid default.
It now bleeds 1, or 2 destroyed, exactly as bodyX says.

NEW GUARD: check-anatomy. Every body plan must cover 1-20 exactly once in BOTH
modes, every location must be drawn and every drawn shape must be a location, and
every kind must be one locationEffectsFor acts on, has effect text for, and has a
destruction outcome. Verified it fails on a d20 gap, a shapeless limb and an
unhandled kind before wiring it in. Ninth guard.

VERIFIED IN A RUNNING FOUNDRY, not just unit-tested: melee bar reads the Melee
column and the rifle reads Ranged on the same creature; forequarters graded as a
vital hit; a maxed wing set flightLost and the sheet said GROUNDED; the real chat
Damage button rolled 1D20 15, found the right foreleg, applied 12 through armour,
disabled the leg and took the dog from 16 to 4; and with nothing targeted nothing
was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:46:11 +01:00

302 lines
13 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 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 { LOCATION_TABLES } from "../anatomy.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));
/* Which BODY a creature is hit-located as. Separate from species on purpose: species
is a playable people with characteristic dice and talents, and a bear is not one.
Without this a four-legged thing could only be authored as a baseline humanoid, so
every hound and horse in the bestiary had two arms and a chest. */
export const BODY_PLAN_KEYS = new Set(Object.keys(LOCATION_TABLES));
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", "bodyPlan", "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 "bodyPlan": return [...BODY_PLAN_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)));
}
if (spec.bodyPlan !== undefined && !BODY_PLAN_KEYS.has(spec.bodyPlan)) {
out.push(problem(`${where}.bodyPlan`, `no such body plan "${spec.bodyPlan}"`,
nearest(spec.bodyPlan, BODY_PLAN_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;
}