Files
RingBRP/tools/outcome-coverage.mjs
T
slaguru666andClaude Opus 5 9c2ce9d905 R-290: check-outcomes — a beat that names a roll must say what it does
Desk pass 7's finding, turned into the nineteenth guard. check-rollable asks
whether a skill is reachable at 25% by the declared cast; pass 5 found that
is rollability, not competence; pass 7 found it is also not coverage. A beat
could be reachable, well-rated and silent about every band but the one the GM
improvises, and the whole suite stayed green.

tools/outcome-coverage.mjs measures. Beats are located structurally — the
bullet that owns the tag and its children, ending at the next bullet of the
same or shallower indent or the next heading, because R-287 is what an
unbounded scope does. Band widths are counted by grading all 100 results
through gradeRoll, never by arithmetic on fumbleStart, which is off by one
and was written wrongly twice this session before being caught. Ratings come
from the declared cast via R-286's reader, best-in-cast per skill, because
that is the die a table actually rolls.

tools/check-outcomes.mjs holds it, in two kinds:

  RATCHET — stated failure/fumble/special counts may improve and may not
  regress; unwritten-band exposure may fall and may not rise. --update
  re-records these, because freezing them would make every improvement fail.

  HARD CLAIMS — read from what is measured NOW, never from the baseline, so
  --update cannot silence them: a floor of 18 beats (check-cited once passed
  with zero citations), no beat bare of every band, every scope structurally
  bounded and none over 5% of the file, no unparseable tag, no beat naming a
  skill the cast has no rating for.

Verified by breaking it: a stripped beat, a broken tag regex, an unparseable
tag and a removed failure case each turn it red; the file and baseline were
restored byte-identical after each; and --update with a bare beat present
re-records the baseline and still fails.

Two bare beats found and filled while building it — the six at the back, and
the Insight that is deliberately indistinguishable on a success and a miss,
which now says so rather than saying nothing. Coverage 16/21 failure, 5
fumble, 4 special at the start; 18/21, 6 and 6 now.

The three figures are cited against the artifact rather than typed, which was
the peer session's condition and the right one: pass 7 hand-counted 22 beats
where there are 21 (Act Three's warning QUOTES a beat, and a hand count reads
the quotation as one — R-286 in the other direction), and its other three
counts were stale within one commit.

Its first catch was its author: the STATUS line announcing this guard
contained a beat-shaped tag and was counted as a beat. The reader was not
changed — prose shaped like a beat is what this file is for — and the error
message now names the fenced block as the place to write an example. Minutes
later check-cited refused a citation-shaped comment in the post-pass about
citations. Three readers, three authors describing their own format inside
it, each caught by a guard built for a different pass.

CLEAN GROUND v0.18. npm run check: 19 guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:47:25 +01:00

180 lines
8.8 KiB
JavaScript

/**
* What a beat OWES a GM, measured rather than asserted.
*
* CLEAN GROUND's house rule is that every clue states its failure case, and the document
* honours it — sixteen of twenty-one beats do. Desk pass 7 found the rule has no sibling:
* the same beats state a fumble five times and a special four times, while a special at a
* rating of 63 is 13% and the scenario asks for twenty-one rolls. Nine sessions in ten
* contain a good roll the document has no answer for, and nothing anywhere noticed, because
* the only guard that reads the acts (check-rollable) asks whether a skill is REACHABLE at
* 25% — not whether it is any good, which pass 5 found, and not whether the beat says what
* happens, which is this.
*
* node tools/outcome-coverage.mjs print the coverage table
* node tools/outcome-coverage.mjs --json the artifact, to stdout
* node tools/outcome-coverage.mjs --update re-record tools/outcome-coverage-baseline.json
*
* THE FIGURES ARE ENUMERATED, NOT COMPUTED. Every band's width is counted by grading all
* 100 results through `gradeRoll`, because `101 - fumbleStart + 1` is off by one and this
* session wrote that arithmetic twice before catching it. Nothing here restates a rule.
*
* A MENTION OF A BEAT AND A BEAT LOOK IDENTICAL TO A REGEX — R-286's finding, in the other
* direction. declared-cast.mjs blanks code spans because a cast declaration inside backticks
* is a mention; every beat here LIVES inside backticks, so the thing to exclude is the
* blockquote. Act Three's warning quotes `[CUS: Anomaly Lore — what a peg is]` to say which
* die fires its twenty-minute bomb, and counting that as a beat would look for outcomes
* around a warning and find the ones belonging to the real beat sixty lines below.
*/
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import path from "node:path";
import { resolveBands, gradeRoll } from "../rules.mjs";
import { ROSTER } from "./roster.mjs";
import { expandFromRegister } from "./expand-spec.mjs";
import { declaredCast, SCENARIO } from "./declared-cast.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
export const BASELINE = path.join(ROOT, "tools", "outcome-coverage-baseline.json");
/* ------------------------------------------------------------------ the beats */
const indentOf = l => { const m = l.match(/^(\s*)(?:[-*]|\d+\.)\s/); return m ? m[1].length : null; };
const isHeading = l => /^#{1,6}\s/.test(l);
/**
* A beat's outcomes live in the bullet that owns it and that bullet's children, and the
* block ENDS STRUCTURALLY — at the next bullet of the same or shallower indent, or at the
* next heading. R-287 is why that last clause is not optional: a scope that fails to find
* its end and silently becomes the file turns a statement about one beat into a statement
* about the document, and stays green doing it.
*/
export function beatsIn(text) {
const lines = text.split("\n");
const out = [];
let inFence = false;
lines.forEach((l, i) => {
if (/^\s*```/.test(l)) { inFence = !inFence; return; }
if (inFence) return;
const quoted = /^\s*>/.test(l);
for (const m of l.matchAll(/\[CUS:\s*([^\]—]+?)\s*(?:—|\])/g)) {
if (quoted) { out.push({ line: i + 1, skill: m[1].trim(), quoted: true }); continue; }
let owner = i;
for (let j = i; j >= 0; j--) {
if (isHeading(lines[j])) break;
if (indentOf(lines[j]) !== null) { owner = j; break; }
}
const ind = indentOf(lines[owner]) ?? 0;
let end = null;
for (let j = owner + 1; j < lines.length; j++) {
const k = indentOf(lines[j]);
if (isHeading(lines[j]) || (k !== null && k <= ind)) { end = j; break; }
}
const body = lines.slice(owner, end ?? lines.length).join("\n");
out.push({
line: i + 1, skill: m[1].trim(), quoted: false,
from: owner + 1, to: end ?? lines.length, bounded: end !== null, chars: body.length,
failure: /\*Missed[:,]|\*\*Failure case:\*\*/.test(body),
fumble: /[Oo]n a (?:FUMBLED|fumble)/.test(body),
special: /[Oo]n a (?:SPECIAL|special|critical)/.test(body)
});
}
});
return out;
}
/** A `[CUS …]` that this reader cannot parse is worse than no tag: somebody thinks it is
one. Asymmetric with beatsIn on purpose — R-286's castLikeIn, same reasoning. */
export function beatLikeIn(text) {
const good = [...text.matchAll(/\[CUS:\s*([^\]—]+?)\s*(?:—|\])/g)].map(m => [m.index, m.index + m[0].length]);
return [...text.matchAll(/\[\s*CUS\b[^\]]*\]/gi)]
.filter(m => !good.some(([a, b]) => m.index >= a && m.index < b))
.map(m => ({ raw: m[0].slice(0, 70), line: text.slice(0, m.index).split("\n").length }));
}
/* ------------------------------------------------------- what a band is worth */
/** Counted by grading every result, never by arithmetic on fumbleStart. */
export function bandWidths(rating) {
const b = resolveBands(rating);
let fumble = 0, special = 0;
for (let r = 1; r <= 100; r++) {
const g = gradeRoll(r, b);
if (g === "fumble") fumble++;
if (g === "special" || g === "critical") special++;
}
return { fumble, special };
}
const FAM = { "repair (mechanical)": "repair", "xenology (baseline)": "xenology", "anomaly lore": "anomaly_lore" };
const famOf = skill => FAM[skill.toLowerCase()] ?? skill.toLowerCase().replace(/\s+/g, "_");
/** The best rating anyone in the DECLARED cast brings to a beat — the rating a table
actually rolls, since a party hands the die to whoever is best at it. */
export function bestRatings(who = "outcome-coverage") {
const cast = declaredCast(who).map(k =>
expandFromRegister(ROSTER.find(r => r.key === `pc_${k}`), { where: who }));
return skill => {
const fam = famOf(skill);
const vals = cast.flatMap(a => (a.skills ?? []).filter(s => s.fam === fam).map(s => s.val));
return vals.length ? Math.max(...vals) : null;
};
}
/** P(at least one roll in the session lands in `band` on a beat that does not state it). */
const anyUnwritten = (beats, band, rating) => 1 - beats
.filter(b => !b[band] && rating(b.skill) !== null)
.reduce((acc, b) => acc * (1 - bandWidths(rating(b.skill))[band] / 100), 1);
const anyAtAll = (beats, band, rating) => 1 - beats
.filter(b => rating(b.skill) !== null)
.reduce((acc, b) => acc * (1 - bandWidths(rating(b.skill))[band] / 100), 1);
export function measure(who = "outcome-coverage") {
const text = readFileSync(SCENARIO, "utf8");
const all = beatsIn(text);
const beats = all.filter(b => !b.quoted);
const rating = bestRatings(who);
const pc = x => Number((100 * x).toFixed(1));
return {
beats: beats.length,
quotedSkipped: all.length - beats.length,
unparsed: beatLikeIn(text).length,
bare: beats.filter(b => !b.failure && !b.fumble && !b.special).map(b => `${b.skill}:${b.line}`),
unrated: beats.filter(b => rating(b.skill) === null).map(b => `${b.skill}:${b.line}`),
widestScope: Math.max(...beats.map(b => b.chars)),
stated: {
failure: beats.filter(b => b.failure).length,
fumble: beats.filter(b => b.fumble).length,
special: beats.filter(b => b.special).length
},
session: {
anyFumble: pc(anyAtAll(beats, "fumble", rating)),
unwrittenFumble: pc(anyUnwritten(beats, "fumble", rating)),
anySpecial: pc(anyAtAll(beats, "special", rating)),
unwrittenSpecial: pc(anyUnwritten(beats, "special", rating))
}
};
}
/* ------------------------------------------------------------------------ cli */
if (import.meta.url === `file://${process.argv[1]}`) {
const argv = process.argv.slice(2);
const m = measure();
if (argv.includes("--json")) { console.log(JSON.stringify(m, null, 2)); process.exit(0); }
if (argv.includes("--update")) {
writeFileSync(BASELINE, JSON.stringify(m, null, 2) + "\n");
console.log(`outcome-coverage: recorded ${path.relative(ROOT, BASELINE)}`);
process.exit(0);
}
const rating = bestRatings();
console.log(`\n ${m.beats} beats (${m.quotedSkipped} quotation skipped), widest scope ${m.widestScope} chars\n`);
console.log(" line fail fum spec best skill");
for (const b of beatsIn(readFileSync(SCENARIO, "utf8")).filter(x => !x.quoted))
console.log(` ${String(b.line).padStart(5)} ${b.failure ? "Y" : "."} ${b.fumble ? "Y" : "."} ${b.special ? "Y" : "."} ${String(rating(b.skill) ?? "—").padStart(4)} ${b.skill}`);
console.log(`\n stated: failure ${m.stated.failure}/${m.beats}, fumble ${m.stated.fumble}, special ${m.stated.special}`);
console.log(` per session: fumble ${m.session.anyFumble}% (${m.session.unwrittenFumble}% unwritten), `
+ `special ${m.session.anySpecial}% (${m.session.unwrittenSpecial}% unwritten)`);
if (m.bare.length) console.log(` BARE (no band stated at all): ${m.bare.join(", ")}`);
if (existsSync(BASELINE)) console.log(`\n baseline: ${path.relative(ROOT, BASELINE)}`);
console.log("");
}