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 clues 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. A desk playtest found it by auditing the acts against the sheets the scenario casts. That audit is mechanical, so it belongs in the build. Two tiers. Corpus-wide, some roster agent must reach VIABLE for every skill any scenario names. Per scenario, a document that DECLARES its cast is held to that cast instead, and that is the tier that catches this defect class. The cast is declared rather than inferred, and that is the interesting part. The first version 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 none, because it reports success. CLEAN GROUND now carries a cast comment and the guard reads it off the raw text, since scenarioText strips HTML comments. Verified load-bearing rather than assumed: re-injecting the original Track tag into the real CLEAN_GROUND.md fails the guard, naming the skill, the base chance and the declared cast. Worth recording that the corpus-wide tier would never have caught it — Lindqvist trains Botany at 40, so it is rollable by the roster and simply not by the six who were cast. The first fixture test passed for that reason and misled me; only the declared-cast tier finds this. VIABLE is 25 and is justified, not picked: 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 worth attempting. Below it a clue is not gated, it is buried. check-scenarios exports its tag parser rather than growing a second copy, behind the invokedDirectly pattern simulate.mjs already uses; a duplicated parser is exactly what this repository's one standing law forbids. update-readme then caught me fairly — it cross-checks the advertised guard list against npm run check — so the guard is registered there too and the README advertises eleven in all three places. No REVIEW_LOG entry: the log is clean at R-260 and is being appended to every few minutes by concurrent work, so the end of that file is the likeliest place to collide. Left for whoever next touches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
6.6 KiB
JavaScript
148 lines
6.6 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";
|
|
|
|
/**
|
|
* 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.
|
|
const declared = raw.match(/<!--\s*cast:\s*([^>]*?)\s*-->/i);
|
|
const cast = declared
|
|
? declared[1].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`);
|