Files
RingBRP/tools/check-kits.mjs
T
slaguru666andClaude Opus 5 b0981008b3 No posting may grant a species
Eighteen postings offered vesh or cadence biology to any agent who took them: unblinking
nine times, quorum seven, distributed three. That is the root cause behind the six
characters check-creatures caught — they were not rogue, they had taken talents their own
postings advertised, and every newly generated agent would have done the same.

Unblinking is the clearest case. It is cat: species, species: vesh, and its own rule text
reads "A Vesh has no face to read and does not need to read yours". Nine postings offered
it, including Interview, Pathology and the doctor trade.

Each of the nineteen slots is replaced with a talent drawn from what the posting actually
does rather than from what was nearest alphabetically: Registry gets Requisition, the
Anchor gets Echo Sense, Disposal gets Clean Scene, Signals gets Chain of Custody and Dead
Reckoning. No pool gained a duplicate, and no talent is left unused — every legal talent
is now offered by at least two postings.

The guard lives in check-kits, which is the file that validates postings, and it imports
TALENT_SPECIES from creature-schema.mjs so there is one definition of which talents are
locked rather than two. check-creatures was looking at characters; nothing was looking at
the pools they were picked from. Verified by reintroducing unblinking on the doctor trade
and watching it fail, not merely by watching it pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:34:37 +01:00

125 lines
6.1 KiB
JavaScript

/**
* Every kit key in ROLES must name a real catalogue item.
*
* The generator used to match kit by fuzzy display name and drop what it could
* not find without saying so, which is how a Stores Officer came to be issued
* five things and receive one. Matching is now exact, so a typo is a silent
* omission instead — unless this runs.
*
* node tools/check-kits.mjs
*/
import { SKILL_CATALOGUE, TALENTS, GEAR, WEAPONS, ARMOURS, DEVICES } from "./content.mjs";
import { SHIELD, SCOPE_TIERS } from "../rules.mjs";
// One definition of which talents are species-locked, shared with check-creatures.
import { TALENT_SPECIES } from "./creature-schema.mjs";
// Imported, not regexed out of the engine source. See postings.mjs.
import { ROLES, TRADES, INDUCTION, CHARACTERISTIC_DICE } from "../postings.mjs";
const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w]));
const ARMOUR_BY_KEY = new Map(ARMOURS.map(a => [a.key, a]));
const skills = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0])));
const talents = new Set(TALENTS.map(t => t.key));
const kit = new Set([...GEAR, ...WEAPONS, ...ARMOURS, ...(DEVICES ?? [])].map(x => x.key));
// Derived, not retyped. The hand-written set still accepted "mas" and "app" —
// which are the DISPLAY NAMES of siz and cha, not keys — so a posting declaring
// stat: "mas" would have passed this guard and then produced an undefined
// characteristic at generation. Latent, but only because nobody had tried it.
const stats = new Set(Object.keys(CHARACTERISTIC_DICE));
const problems = [];
const kitSets = new Map();
const issued = new Map();
for (const [id, r] of Object.entries({ ...ROLES, ...TRADES })) {
const where = `${id} (${r.label})`;
if (!stats.has(r.stat)) problems.push(`${where}: stat "${r.stat}" is not a characteristic`);
for (const s of [...r.core, ...r.support]) if (!skills.has(s)) problems.push(`${where}: no skill "${s}"`);
for (const t of r.talents) if (!talents.has(t)) problems.push(`${where}: no talent "${t}"`);
// A posting is a JOB. It cannot offer a talent that is a SPECIES, because a species is
// not something the department trains you in. Eighteen postings offered vesh or cadence
// biology to any agent who took them — nine of them Unblinking, whose own rule text
// reads "A Vesh has no face to read" — and five roster agents and a starter pregen duly
// ended up carrying it. check-creatures caught the characters; nothing was looking at
// the pools they were picked from, so the bug regenerated for every new agent.
// The species map comes from creature-schema.mjs so there is one definition of which
// talents are locked, not two.
for (const t of r.talents) {
const locked = TALENT_SPECIES.get(t);
if (locked) problems.push(`${where}: offers "${t}", which is ${locked}-only — a posting cannot grant a species`);
}
for (const g of r.kit) {
if (!kit.has(g)) problems.push(`${where}: no catalogue item "${g}"`);
issued.set(g, (issued.get(g) ?? 0) + 1);
}
// A posting issued a weapon it has no skill for carries it at the 5% base. Ten
// of the twenty new roles did exactly that, and only a play-through found it —
// an Antiquities Officer drew a flintlock and rolled 5% with it.
const trained = new Set([...r.core, ...r.support]);
for (const k of r.kit) {
const w = WEAPON_BY_KEY.get(k);
if (!w?.fam || k === "punch") continue;
const need = w.spec ? `${w.fam}:${w.spec}` : w.fam;
if (!trained.has(need)) problems.push(`${where}: issued ${w.name}, trains no ${need}`);
}
// A shield is armour you defend WITH, so it needs its skill for exactly the same
// reason a weapon does — a posting issued one and untrained in it parries at the
// 5% base and would be better off with both hands free.
const shieldSkill = `${SHIELD.parryFamily}:${SHIELD.parrySpec}`;
for (const k of r.kit) {
const a = ARMOUR_BY_KEY.get(k);
if (!a?.shield) continue;
if (!trained.has(shieldSkill)) {
problems.push(`${where}: issued ${a.name}, trains no ${shieldSkill}`);
}
}
const sig = JSON.stringify([...r.kit].sort());
if (kitSets.has(sig)) problems.push(`${where}: identical loadout to ${kitSets.get(sig)}`);
kitSets.set(sig, id);
}
// A Borrowed Authority whose scope is not a real tier reads as rank 0 — "nothing" —
// and every single action through it comes back "impossible" with no error anywhere.
// The starter scenario shipped with scope "operate", which is not a tier, and the only
// symptom was a credential that silently did not work.
{
const S = await import("./scenario-starter.mjs").catch(() => null);
const auth = S?.STARTER_AUTHORITY;
// A case may legitimately carry no credential; one that does must name a real tier.
if (auth && !SCOPE_TIERS.includes(auth.scope)) {
problems.push(`starter authority "${auth.name}": scope "${auth.scope}" is not a `
+ `scope tier (have: ${SCOPE_TIERS.join(", ")})`);
}
}
// INDUCTION is handed to every trade character, so its keys are checked once here
// rather than ten times above.
for (const k of INDUCTION.skills) {
if (!skills.has(k)) problems.push(`induction: no skill "${k}"`);
}
for (const g of INDUCTION.kit) {
if (!kit.has(g)) problems.push(`induction: no catalogue item "${g}"`);
const w = WEAPON_BY_KEY.get(g);
if (w?.fam) {
const need = w.spec ? `${w.fam}:${w.spec}` : w.fam;
if (!INDUCTION.skills.includes(need)) {
problems.push(`induction: issues ${w.name}, teaches no ${need}`);
}
}
}
// Every posting should be issued at least one thing no other posting gets. This
// is a design rule, not a correctness one, so it warns rather than fails.
const noSignature = Object.entries(ROLES)
.filter(([, r]) => !r.kit.some(g => issued.get(g) === 1))
.map(([id]) => id);
if (problems.length) {
console.error("check-kits: FAILED\n " + problems.join("\n "));
process.exit(1);
}
console.log(`check-kits: OK — ${Object.keys(ROLES).length} roles, `
+ `${Object.keys(TRADES).length} trades, `
+ `${kit.size} catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct`);
if (noSignature.length) console.warn(` note: no item unique to ${noSignature.join(", ")}`);