From daf77f4aac94987a2ef51f04c5dc8165bdf564e7 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:05:03 +0100 Subject: [PATCH] R-293: a desk playtest is a record, not a scenario with rolls in it 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 --- docs/REVIEW_LOG.md | 36 +++++++++++++++++++++++++++ tools/check-behaviour.mjs | 21 +++++++++++++++- tools/check-rollable.mjs | 12 ++++++--- tools/check-scenarios.mjs | 51 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 544a1ba..38e0f30 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -6894,3 +6894,39 @@ refused, 19 guards green. Wrong. `beatsIn` returns every marker and `measure()` filters the quoted one — Anomaly Lore at line 758, Act Three's quotation of the format. 22 markers, 21 real beats. Both figures were right and the disagreement was mine. + +## R-293 — a record of a session is not a scenario + +The fifth self-reference case, and the only live one: counting the readers that had broken on +their own format turned up a sixth that nothing had ever caught. `docs/scenarios/` holds three +kinds of document — the scenarios, eight desk playtest records, and two art prompt sheets — +and every guard reading the corpus treated all three as scenarios. Harmless for citations and +skill spellings. False for reachability. + +check-rollable's claim is that **every clue a GM can put behind a roll is reachable by somebody +at the table.** A playtest report quoting `` `[CUS: Anomaly Lore — what a peg is]` `` to say +which beat a roll hung off had that quotation held to the claim. Six such quotations across +passes 4, 6 and 7 were being checked as live rolls, and a record of a session already played +could fail the build over a skill nobody can reach. Measured: planting `Science (Physics)` in +pass 4 gives *"a clue is behind a roll nobody present can make"* before the split and passes +after it, while the same skill in CLEAN_GROUND still fails. 89 rolls across 18 files becomes +83 across 8. + +**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. A generator has no H1 and is a scenario by +construction. + +**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 — +the fail-open this session has spent its whole length removing. `classifiedFiles()` is called +by check-scenarios, the first guard over the corpus, so the message arrives at the right guard, +and it names the corpus rather than a guard because it runs inside whichever one asked first. + +**Two things I had wrong on the way**, both caught by insisting on a measurement. The first +probe used `Demolition` and passed everywhere, which looked like the guard being blind; it is +reachable, and the playtest files have no cast marker so they were being checked against the +whole duty roster rather than the declared six. The claim "would have failed the build" was +hypothetical until a skill nobody on the roster reaches proved it. And the first version of the +new test ended in `assert.ok(… || true)` — a tautology that passes on anything, which is the +decoration this suite exists to refuse. Mutation-checked after removing it: making +`playableFiles` stop excluding records turns it red. diff --git a/tools/check-behaviour.mjs b/tools/check-behaviour.mjs index 4ee341f..c3020b8 100644 --- a/tools/check-behaviour.mjs +++ b/tools/check-behaviour.mjs @@ -32,7 +32,7 @@ import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS, CHARACTERISTIC_DICE } import { STARTER_AUTHORITY, STARTER_GROUPS, STARTER_CASE, STARTER_TEAM } from "./scenario-starter.mjs"; import { castMarkersIn, castLikeIn } from "./declared-cast.mjs"; -import { tagsIn } from "./check-scenarios.mjs"; +import { tagsIn, classifiedFiles, playableFiles } from "./check-scenarios.mjs"; import { beatsIn, beatLikeIn } from "./outcome-coverage.mjs"; let passed = 0, failed = 0; @@ -851,6 +851,25 @@ test("a tag one reader accepts is never invisible to the other", () => { } }); +/* ── R-293: a record of a session is not a scenario ────────────────────────────────── + check-rollable's claim is that every clue a GM can put behind a roll is reachable by + somebody at the table. The corpus it read held three kinds of document and only one is a + thing a GM runs, so a desk playtest quoting `[CUS: Anomaly Lore — what a peg is]` to say + which beat a roll hung off had that quotation held to the claim. Measured, not assumed: + planting an unreachable skill in a playtest report failed the build before this split. */ + +test("the corpus knows which of its documents are played and which are records", () => { + const kinds = classifiedFiles(); + assert.ok(kinds.length >= 10, "the corpus has shrunk unexpectedly"); + assert.ok(kinds.every(([, , kind]) => kind === "playable" || kind === "record"), + "classifiedFiles exits on an unclassified document, so nothing here should be null"); + const playable = playableFiles().map(([l]) => l); + assert.ok(playable.includes("docs/scenarios/CLEAN_GROUND.md")); + assert.ok(!playable.some(l => /PLAYTEST/i.test(l)), + "a desk playtest is a record of a session already played; its quoted beats are not rolls"); + assert.ok(!playable.some(l => /_ART\.md$/.test(l)), "an art prompt sheet has no rolls"); +}); + /* ---------------------------------------------------------------- */ await runAll(); diff --git a/tools/check-rollable.mjs b/tools/check-rollable.mjs index d380c7d..2b08381 100644 --- a/tools/check-rollable.mjs +++ b/tools/check-rollable.mjs @@ -38,7 +38,7 @@ 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"; +import { playableFiles, scenarioText, tagsIn, skillsIn } from "./check-scenarios.mjs"; import { castMarkersIn, castLikeIn } from "./declared-cast.mjs"; /** @@ -89,7 +89,11 @@ const problems = []; const report = []; let checkedTags = 0; -for (const [label, path] of scenarioFiles()) { +/* 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); @@ -174,5 +178,5 @@ if (problems.length) { 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`); +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`); diff --git a/tools/check-scenarios.mjs b/tools/check-scenarios.mjs index 0a5dfb4..83c1a81 100644 --- a/tools/check-scenarios.mjs +++ b/tools/check-scenarios.mjs @@ -107,6 +107,56 @@ export function scenarioFiles() { ]; } +/* WHAT A DOCUMENT IS, not what it is named. The corpus above holds three kinds and only one + of them is a thing a GM runs: the scenarios, the desk playtest records, and the art prompt + sheets. Every guard reading the corpus has treated all three as scenarios, which was + harmless for citations and skill spellings and false for reachability — a playtest report + quoting `[CUS: Anomaly Lore — what a peg is]` to say which beat a roll hung off had that + quotation checked as a live roll, and would have failed the build over a skill nobody can + reach in a document that is a record of a session already played. + + Classified by the document's own H1 rather than its filename, because `tools/scenario-*` + naming is what swept a tools file into this corpus in R-268. A generator has no H1 and is + a scenario by construction. + + AN UNCLASSIFIED FILE 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 added, which is + the fail-open this suite has spent the session removing. Same rule as powers.mjs: a thing + the tools read must be classified, and the build says so if it is not. */ +const PLAYABLE_H1 = /^(Crossing case|Crossing starter|Prologue)\b/; +const RECORD_H1 = /desk playtest \d|art(work)? prompt/i; + +/** [label, path, kind] for every corpus file; kind is "playable" or "record". */ +export function classifiedFiles() { + const unknown = []; + const out = scenarioFiles().map(([label, abs]) => { + if (label.startsWith("tools/scenario-")) return [label, abs, "playable"]; + const h1 = (readFileSync(abs, "utf8").match(/^#\s+(.+)$/m) ?? [, ""])[1].trim(); + if (PLAYABLE_H1.test(h1)) return [label, abs, "playable"]; + if (RECORD_H1.test(h1)) return [label, abs, "record"]; + unknown.push(`${label} — its heading reads "${h1.slice(0, 70)}"`); + return [label, abs, null]; + }); + if (unknown.length) { + /* Named for the corpus, not for a guard: this runs inside whichever guard asked first, + and a message saying "check-scenarios" while check-rollable is on screen sends the + reader to the wrong file. */ + console.error(`scenario corpus: FAILED — ${unknown.length} document(s) in docs/scenarios are ` + + `neither a scenario nor a record, so nothing knows whether their rolls are live:`); + unknown.forEach(u => console.error(` ${u}`)); + console.error(` A scenario's heading starts "Crossing case", "Crossing starter" or ` + + `"Prologue"; a record's says "desk playtest N" or "art prompt". Guessing would put a ` + + `new scenario outside reachability checking on the day it was written.`); + process.exit(1); + } + return out; +} + +/** The corpus files a GM actually runs — the only ones whose rolls must be reachable. */ +export function playableFiles() { + return classifiedFiles().filter(([, , kind]) => kind === "playable").map(([l, a]) => [l, a]); +} + /** Lines that say a skill does NOT exist are documentation, not an instruction. */ export function scenarioText(raw) { return stripHtml(raw) @@ -115,6 +165,7 @@ export function scenarioText(raw) { .join("\n"); } +classifiedFiles(); // every corpus document must be a scenario or a record; fatal if not const files = scenarioFiles(); const problems = [];