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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 16:05:03 +01:00
co-authored by Claude Opus 5
parent 6171d1e9b9
commit daf77f4aac
4 changed files with 115 additions and 5 deletions
+36
View File
@@ -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.
+20 -1
View File
@@ -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();
+8 -4
View File
@@ -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`);
+51
View File
@@ -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 = [];