/** * 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: * * * * 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 \`\` (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`);