diff --git a/README.md b/README.md index d7f3050..9fc3fe1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ packs or regenerate art on that machine: ```bash npm install # pulls classic-level, used to write the LevelDB packs npm run build # rebuilds packs/ from tools/content.mjs -npm run check # the ten guards; the build refuses to run if they fail +npm run check # the eleven guards; the build refuses to run if they fail ``` To update a deployed server: `git pull` and restart Foundry. If the pull touches @@ -157,7 +157,7 @@ icons/ fonts/ art/ generated art packs/ built LevelDB compendia (committed — see Deploying) tools/ content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs - build-packs.mjs builds packs/ — runs the ten guards first and refuses on failure + build-packs.mjs builds packs/ — runs the eleven guards first and refuses on failure make-icons.mjs draws all 231 icons rules-text.mjs generates the rules journal FROM rules.mjs mission.mjs the case generator @@ -166,6 +166,7 @@ tools/ check-kits.mjs guard: every posting's kit must exist and be usable check-lang.mjs guard: no localisation key may be both a value and a branch check-lethality.mjs guard: no creature may quietly change what it does to a party + check-rollable.mjs guard: no clue may hide behind a roll nobody present can make docs/REVIEW_LOG.md the design and defect log, R-1 onward ``` @@ -177,16 +178,17 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a literal, or if an exported rule has no spot-check. The packs used to be built under one set of numbers and played under another; this makes that impossible to ship. -The build runs all ten guards before it writes anything: +The build runs all eleven guards before it writes anything: ``` -check-rules: OK — 110 rules, 35 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified +check-rules: OK — 110 rules, 36 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct check-lang: OK — en.json, 1018 keys, no leaf/branch collisions check-templates: OK — 19 templates compile check-behaviour: OK — 76 behavioural tests check-scenarios: OK — 11 scenario files, 79 mechanic tags, every skill named resolves against 62 catalogue entries +check-rollable: OK — 78 rolls across 11 scenario files, every one reachable at 25% or better by somebody present check-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys check-anatomy: OK — 5 body plans, 36 locations, d20 complete in both modes, every location drawn, every kind handled and destructible, 20 plan changes carried 432 wounds with their severity intact, 1 flyer(s) with a survivable landing check-lethality: OK — 47 creatures, every one fighting exactly as recorded against the frozen party diff --git a/docs/scenarios/CLEAN_GROUND.md b/docs/scenarios/CLEAN_GROUND.md index a5988c1..24fc48b 100644 --- a/docs/scenarios/CLEAN_GROUND.md +++ b/docs/scenarios/CLEAN_GROUND.md @@ -677,6 +677,8 @@ No roll resolves this. No NPC advises. Registry signs whatever they write. ## Casting — six from the duty roster + + **Nothing new is built.** All six are existing compendium agents (`packs/pregens`, defined in `tools/roster.mjs`), so their numbers cannot drift from the register and `check-creatures` already validates them. **The compendium is the authority for every diff --git a/package.json b/package.json index 2edaa2b..53f430d 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "bestiary": "node tools/bestiary.mjs", "mj": "node tools/mj-queue.mjs", "simulate": "node tools/simulate.mjs", - "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-bestiary.mjs", + "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-bestiary.mjs", "test": "bun run check", "readme": "bun tools/update-readme.mjs" }, diff --git a/tools/check-rollable.mjs b/tools/check-rollable.mjs new file mode 100644 index 0000000..d18d49e --- /dev/null +++ b/tools/check-rollable.mjs @@ -0,0 +1,147 @@ +/** + * 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 { 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(//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`); diff --git a/tools/check-scenarios.mjs b/tools/check-scenarios.mjs index 7001d23..403cd6c 100644 --- a/tools/check-scenarios.mjs +++ b/tools/check-scenarios.mjs @@ -14,7 +14,7 @@ import { readFileSync, readdirSync } from "node:fs"; import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { SKILL_CATALOGUE } from "./content.mjs"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -45,7 +45,7 @@ const NOISE = new Set([ * "Ride, then Athletics" -> ["Ride", "Athletics"] * "Persuade at −30% on the first pass" -> ["Persuade"] */ -function skillsIn(body) { +export function skillsIn(body) { // everything before the first descriptive break belongs to the roll itself const head = body.split(/[—–—;:]|\bat\b|\bwhen\b|\bif\b|\bfor\b|\bagainst\b/u)[0]; return head @@ -54,14 +54,14 @@ function skillsIn(body) { .filter(x => x && !NOISE.has(x.toLowerCase()) && !/^\d/.test(x)); } -function tagsIn(text) { +export function tagsIn(text) { // [CUS: ...] as written in the markdown, and the HTML-escaped form in the generators const out = []; for (const m of text.matchAll(/\[CUS:\s*([^\]]+)\]/g)) out.push(m[1]); return out; } -function stripHtml(s) { +export function stripHtml(s) { // The generators build these strings by concatenation, so a single tag is routinely // split across two source lines. Rejoin before anything tries to read it. return s.replace(/"\s*\+\s*"/g, "") @@ -71,23 +71,38 @@ function stripHtml(s) { .replace(/½/g, "").replace(/“|”/g, '"'); } -const files = [ - ...readdirSync(join(ROOT, "tools")).filter(f => f.startsWith("scenario-")) - .map(f => ["tools/" + f, join(ROOT, "tools", f)]), - ...readdirSync(join(ROOT, "docs", "scenarios")).filter(f => f.endsWith(".md")) - .map(f => ["docs/scenarios/" + f, join(ROOT, "docs", "scenarios", f)]) -]; +/** + * Every scenario source and GM document, as [label, absolute path]. + * + * Exported because check-rollable reads exactly the same corpus, and the corpus rule is + * a definition like any other — two guards disagreeing about what counts as a scenario + * is how a scenario stops being checked without anybody noticing. + */ +export function scenarioFiles() { + return [ + ...readdirSync(join(ROOT, "tools")).filter(f => f.startsWith("scenario-")) + .map(f => ["tools/" + f, join(ROOT, "tools", f)]), + ...readdirSync(join(ROOT, "docs", "scenarios")).filter(f => f.endsWith(".md")) + .map(f => ["docs/scenarios/" + f, join(ROOT, "docs", "scenarios", f)]) + ]; +} + +/** Lines that say a skill does NOT exist are documentation, not an instruction. */ +export function scenarioText(raw) { + return stripHtml(raw) + .split("\n") + .filter(l => !/there is no .* skill|NO RIDE SKILL/i.test(l)) + .join("\n"); +} + +const files = scenarioFiles(); const problems = []; let tagCount = 0; for (const [label, path] of files) { const raw = readFileSync(path, "utf8"); - // A line that explicitly says a skill does NOT exist is documentation, not an instruction. - const text = stripHtml(raw) - .split("\n") - .filter(l => !/there is no .* skill|NO RIDE SKILL/i.test(l)) - .join("\n"); + const text = scenarioText(raw); for (const body of tagsIn(text)) { tagCount++; for (const cand of skillsIn(body)) { @@ -98,6 +113,12 @@ for (const [label, path] of files) { } } +const invokedDirectly = process.argv[1] + && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (invokedDirectly) run(); + +function run() { if (problems.length) { console.error("check-scenarios: FAILED"); for (const p of [...new Set(problems)]) console.error(" " + p); @@ -106,3 +127,4 @@ if (problems.length) { console.log(`check-scenarios: OK — ${files.length} scenario files, ${tagCount} mechanic ` + `tags, every skill named resolves against ${SKILL_CATALOGUE.length} catalogue entries`); +} diff --git a/tools/update-readme.mjs b/tools/update-readme.mjs index 7236afd..56860fc 100644 --- a/tools/update-readme.mjs +++ b/tools/update-readme.mjs @@ -35,8 +35,8 @@ const runGuard = name => { every guard added after it was written — including the three that catch the most. */ const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs", "check-templates.mjs", "check-behaviour.mjs", "check-scenarios.mjs", - "check-creatures.mjs", "check-anatomy.mjs", "check-lethality.mjs", - "check-bestiary.mjs"]; + "check-rollable.mjs", "check-creatures.mjs", "check-anatomy.mjs", + "check-lethality.mjs", "check-bestiary.mjs"]; /* The list above went stale the moment a guard was added without touching this file — which is what the comment above it already warned about, and which happened again