check-rollable: guard eleven, because a real skill is not the same as a route
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
48216bae10
commit
555fa761c0
@@ -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:
|
||||
|
||||
<!-- guards:start -->
|
||||
```
|
||||
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
|
||||
|
||||
@@ -677,6 +677,8 @@ No roll resolves this. No NPC advises. Registry signs whatever they write.
|
||||
|
||||
## Casting — six from the duty roster
|
||||
|
||||
<!-- cast: pc_ashcroft, pc_bhattacharya, pc_renshaw, pc_braithwaite, pc_pollard, pc_okonkwo -->
|
||||
|
||||
**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
|
||||
|
||||
+1
-1
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
* <!-- 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`);
|
||||
+37
-15
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user