The starter is now an action case: a train running through a station whose rails were lifted in 1976, two engineers aboard, eleven minutes until it returns. Descend, take the platform, board, rescue, fight forward along a moving train, stop it. Simpler than PAPER WINDOW on purpose — one clue scene, no social subsystem — which also means it cannot reproduce R-248, because there are no groups or standings to leave unwired. Every agent can fight, deliberately: floors of firearm 42%, melee 40%, Brawl 40%, Dodge 40% so nobody is a spectator on their first evening. Floors only RAISE, so a posting already better stays better — Callum's shield lands at 53, not the floor's 45. The builder now handles combat floors, extra weapons issued for a case, and a scenario with NO Borrowed Authority at all; the last was assumed to exist and would have thrown. Two new tests: every floor names a real skill, and every agent clears the fighting floors the handout promises. Sixty-two dialog strings reworded. They were naming mechanics at people: "Situational modifier %" is now "Anything else helping or hurting? (%)", "Target cover" is "Is the target behind anything?", "Difficult (÷2)" is "Difficult — half your skill". Labels ask the question; hints say what it does to the number; notifications say what to do next rather than what went wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
111 lines
5.1 KiB
JavaScript
111 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;
|
|
// 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(", ")}`);
|