Files
RingBRP/tools/check-rollable.mjs
slaguru666andClaude Opus 5 daf77f4aac R-293: a desk playtest is a record, not a scenario with rolls in it
Counting the readers that had broken on their own format turned up one nothing had
caught. docs/scenarios holds scenarios, eight playtest records and two art prompt
sheets, and every guard treated all three as scenarios -- harmless for citations
and skill spellings, false for reachability. Six quotations across passes 4, 6 and
7 were checked as live rolls, so a record of a session already played could fail
the build over a skill nobody can reach. Planting Science (Physics) in pass 4 fails
before the split and passes after; the same skill in CLEAN_GROUND still fails.

Classified by the document's own H1, not its filename, because tools/scenario-*
naming is what swept a tools file into this corpus in R-268. An unclassified
document is fatal: an allowlist that silently drops what it does not recognise
would take a new scenario out of reachability checking on the day it was written.

89 rolls across 18 files becomes 83 across 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:05:03 +01:00

183 lines
9.2 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 { playableFiles, scenarioText, tagsIn, skillsIn } from "./check-scenarios.mjs";
import { castMarkersIn, castLikeIn } 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;
/* playableFiles(), not the whole corpus: this guard's claim is that every clue a GM can put
behind a roll is reachable by somebody at the table. A desk playtest report quoting a beat
to say which one a roll hung off is not a roll anybody will make, and holding it to that
claim means a record of a session already played can fail the build. */
for (const [label, path] of playableFiles()) {
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);
for (const odd of castLikeIn(raw)) {
problems.push(`${label}: line ${odd.line} looks like a cast declaration and is not one this `
+ `guard can read — \`${odd.raw}\`. The shape is \`<!-- cast: key, key -->\` (case and `
+ `spaces around the colon are fine). Unread, it declares nothing and every skill in the `
+ `document is checked against the whole duty roster instead. If it is prose, put it in `
+ `backticks or a fenced block, where nothing here looks at it.`);
}
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.`);
}
/* A MARKER THAT DECLARES NOBODY MUST NOT FALL BACK. No marker at all is the ordinary case —
sixteen of the seventeen scenarios here have none and are rightly checked against the whole
duty roster. A marker that is PRESENT and EMPTY is a different thing: somebody wrote a
declaration and this guard read nothing out of it, and the fallback then widens the
population silently, so a clue only Pollard can reach passes on the strength of an agent
the scenario does not cast. That widening is what made R-286 green rather than loud; the
reader is unambiguous now, and this closes the door it came through. c0 asked for it. */
if (markers.length === 1 && !markers[0].keys.trim()) {
problems.push(`${label}: declares a cast at line ${markers[0].line} and names nobody in it. `
+ `An empty declaration is not the same as no declaration — this guard would fall back to `
+ `the full duty roster and check every skill in the document against people the scenario `
+ `does not cast, and say OK. Name the cast or delete the marker.`);
}
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 ${playableFiles().length} playable `
+ `scenarios (records and art sheets are not rolls), every one reachable at ${VIABLE}% or better by somebody present`);