Files
RingBRP/tools/check-rollable.mjs
T
slaguru666andClaude Opus 5 0453255ecc R-286: one cast declaration, and a mention of one is not a declaration
CLEAN_GROUND.md contains two things matching <!-- cast: ... -->: the real
declaration, and the warning ten lines below it that quotes the marker inside
backticks and matches with an empty capture. check-rollable and declared-cast both
took .match(), first hit wins, so the arrangement has been correct only because the
declaration comes first.

Move that warning above the list and check-rollable reads a cast of nobody, falls
back to ROSTER_BEST, and prints the same OK line having held every skill in the
document to the full duty roster instead of the declared six. Instrumented and read
off: "the duty roster" against "its declared cast of 6".

castMarkersIn blanks code spans and fenced blocks before scanning (padded, so line
numbers still point at the source) and refuses more than one surviving marker,
naming every line. check-rollable imports it instead of carrying a second regex, so
the guard that checks the cast and the guards that measure the fight cannot
disagree about who is in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:30:36 +01:00

159 lines
7.3 KiB
JavaScript

/**
* check-rollable — a clue may not hide behind a roll nobody at the table can make.
*
* check-scenarios resolves every skill a scenario NAMES against the catalogue, which is a
* different question from whether anybody present can roll it. CLEAN GROUND shipped four
* commits with three clue routes gated on Track (base 10), Navigate (base 10) and Science
* (Botany) — base 1, so the 1% floor was the whole of it — and every guard passed, because
* all three are perfectly real skills. The defect only surfaced when a desk playtest
* audited the acts against the sheets the scenario actually casts. That audit is
* mechanical, so it belongs here rather than in somebody's afternoon.
*
* TWO TIERS.
*
* Corpus-wide: for every skill any scenario names, SOMEBODY on the duty roster must be
* able to roll it at or above VIABLE. A scenario may bring any roster agent, so a skill
* no roster agent can roll is a dead route whoever turns up.
*
* Per scenario: a document that DECLARES its cast is held to that cast instead. This is
* the strict check, and the one that catches a scenario recommending six sheets and then
* asking for a seventh one's skill. The declaration is a machine-readable line:
*
* <!-- cast: pc_ashcroft, pc_bhattacharya, pc_renshaw -->
*
* It is declared rather than inferred on purpose. The first version of this guard
* scraped `pc_` keys out of the prose and swept up the substitutes named in CLEAN
* GROUND's player-count scaling — a cast of nine instead of six, which put Sandoval and
* his Track 35 in scope and made the guard pass the very bug it was written for. A guard
* that guesses the cast is worse than no guard, because it reports success.
*
* It does not know any rules. Skill values come from expandFromRegister, the same
* expansion the packs are built from, so a sheet cannot be checked at numbers it will not
* be played at.
*
* node tools/check-rollable.mjs the guard
* node tools/check-rollable.mjs --report every scenario's worst routes, pass or fail
*/
import { readFileSync } from "node:fs";
import { SKILL_CATALOGUE } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { expandFromRegister } from "./expand-spec.mjs";
import { scenarioFiles, scenarioText, tagsIn, skillsIn } from "./check-scenarios.mjs";
import { castMarkersIn } from "./declared-cast.mjs";
/**
* The lowest chance that still counts as a route, in percent.
*
* 25 is not arbitrary: it is the commonest base chance in the catalogue — what an
* untrained agent brings to Spot, Listen or Brawl — so it is the level the game itself
* treats as "you may reasonably try this". Below it a clue is not gated, it is buried:
* at 10 a GM is improvising a bridge four times in five, and at 1 the tag is decoration.
*/
const VIABLE = 25;
/** Tag bodies that are telling the GM NOT to roll are not routes and are not checked. */
const NOT_A_ROLL = /\bno roll\b|\bautomatic\b|\bnot Scent\b/i;
/** best[skill name, lowercased] = {val, who} across a set of expanded sheets. */
function rollability(specs) {
const best = {};
const offer = (name, val, who) => {
const k = name.toLowerCase();
if (!best[k] || val > best[k].val) best[k] = { val, who };
};
for (const spec of specs) {
const e = expandFromRegister(spec);
for (const sk of e.skills || []) {
const full = sk.fam + (sk.spec ? `(${sk.spec})` : "");
// Tags name skills as a GM speaks them: "Repair (Mechanical)", or just "Repair".
const row = SKILL_CATALOGUE.find(r => r[0] === sk.fam && (r[1] || "") === (sk.spec || ""));
const label = row ? (row[4] || row[0]) : full;
offer(label, sk.val, e.name);
offer(label.replace(/\s*\([^)]*\)\s*$/, "").trim(), sk.val, e.name); // the family alone
}
}
return best;
}
/** Untrained chance for a named skill, so a report can say why something is unrollable. */
const baseFor = name => {
const n = name.toLowerCase();
const row = SKILL_CATALOGUE.find(r => (r[4] || r[0]).toLowerCase() === n)
?? SKILL_CATALOGUE.find(r => (r[4] || r[0]).replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase() === n);
return row ? row[3] : null;
};
const ROSTER_BEST = rollability(ROSTER);
const problems = [];
const report = [];
let checkedTags = 0;
for (const [label, path] of scenarioFiles()) {
const raw = readFileSync(path, "utf8");
const text = scenarioText(raw);
/* Read the declaration off the RAW text: scenarioText strips HTML, comments included.
Through castMarkersIn rather than a regex of its own, so this guard and the one that
measures the fight cannot disagree about who is cast — and so a marker mentioned in
prose is not read as a second declaration. CLEAN_GROUND.md carries one of each. */
const markers = castMarkersIn(raw);
if (markers.length > 1) {
problems.push(`${label}: declares a cast ${markers.length} times, at lines `
+ `${markers.map(m => m.line).join(", ")}. This guard read the first and held every `
+ `skill in the document to it; the others were never seen. Keep one declaration and `
+ `put any example inside backticks, where the reader ignores it.`);
}
const declared = markers[0];
const cast = declared
? declared.keys.split(",").map(x => x.trim()).filter(Boolean).map(k => {
const spec = ROSTER.find(r => r.key === k);
if (!spec) {
problems.push(`${label}: cast declares "${k}", which is not on the duty roster`);
}
return spec;
}).filter(Boolean)
: [];
const scope = cast.length ? rollability(cast) : ROSTER_BEST;
const scopeName = cast.length ? `its declared cast of ${cast.length}` : "the duty roster";
for (const body of tagsIn(text)) {
if (NOT_A_ROLL.test(body)) continue;
checkedTags++;
for (const cand of skillsIn(body)) {
const hit = scope[cand.toLowerCase()];
const base = baseFor(cand);
const val = hit ? hit.val : (base ?? 0);
if (val >= VIABLE) continue;
const held = hit ? `best ${hit.val}% (${hit.who})` : `untrained, base ${base ?? "?"}%`;
problems.push(`${label}: "${cand}" — ${held}, against ${scopeName}\n`
+ ` ${("[CUS: " + body.trim() + "]").slice(0, 100)}`);
}
for (const cand of skillsIn(body)) {
const hit = scope[cand.toLowerCase()];
if (hit) report.push([label, cand, hit.val, hit.who, scopeName]);
}
}
}
if (process.argv.includes("--report")) {
const worst = report.sort((a, b) => a[2] - b[2]).slice(0, 20);
console.log(`check-rollable --report — the twenty thinnest routes in the corpus\n`);
for (const [file, skill, val, who, scopeName] of worst) {
console.log(` ${String(val).padStart(3)}% ${skill.padEnd(22)} ${who.padEnd(14)} `
+ `${file.replace("docs/scenarios/", "")} (${scopeName})`);
}
process.exit(0);
}
if (problems.length) {
console.error("check-rollable: FAILED — a clue is behind a roll nobody present can make");
for (const p of [...new Set(problems)]) console.error(" " + p);
console.error(`\n A route must reach ${VIABLE}% for somebody. Move the clue onto a skill `
+ `the cast holds,\n make it automatic, or cast somebody who has it.`);
process.exit(1);
}
console.log(`check-rollable: OK — ${checkedTags} rolls across ${scenarioFiles().length} `
+ `scenario files, every one reachable at ${VIABLE}% or better by somebody present`);