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>
183 lines
9.2 KiB
JavaScript
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`);
|