Files
RingBRP/tools/check-kits.mjs
T
slaguru666andClaude Opus 5 4e28de2a64 Play PAPER WINDOW with the shipped pregens; fix four silent defects
Ran the starter end to end. It closed, and it found four defects in what I shipped an
hour earlier — all in the scenario data, all silent.

R-247: the Borrowed Authority did not work. I wrote scope "operate", which is not one
of the five scope tiers; an unrecognised scope ranks 0 and every action came back
"impossible" with no error. The first scene of the starter is the Borrowed Authority
tutorial. Fixed, guarded in check-kits, and covered by a test that documents the
failure mode.

R-248: the contact subsystem was unreachable in the scenario built around it. The case
file carried a standing for the Returned Clerks and no GROUP ITEM; resolveContact
returns null without one, so READ, SIGNAL and OFFER did nothing. Groups are data now,
and a test asserts every standing on a shipped case file has a group behind it.

R-249: legs wrote `kindId` where resolveLegDialog reads `leg.kind`. It worked only
because the terrain text happens to begin with "Extraction".

Not a defect: phase four went Difficult because Resources hit 0 and destitution makes
every remaining phase Difficult. Best beat in the session.

All four phases failed and the case still closed — Containment 8 to 2, Resources 8 to
0 — which is what the scenario is built to survive. Two of six closing requirements
succeeded and the settlement still completed. The Cancellation Man opened with a
critical against a raised riot shield and went through 10 points of armour, teaching
the shield and graded-defence lessons in one roll.

Also documented: nobody on the starter team can aim the return, because only the Anchor
Officer trains Transposition. The extraction page now says to narrate it rather than
roll six dice that will all fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:39:43 +01:00

110 lines
5.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";
// 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}"`);
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;
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(", ")}`);