/** * 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"; import { tagRe } from "./check-scenarios.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 */ /* THE OPENING IS SHARED, THE BODY IS NOT — R-292, and the distinction is the point. The two readers must agree about what STARTS a tag, and they had drifted there: `\[CUS:` was copied into both files and widened in only one. They must NOT share a body, because check-scenarios captures the whole tag for `skillsIn` while this file stops at the em-dash, with the outcome text after it. So `TAG_OPEN` comes from check-scenarios and the body stays here. `tagRe` builds a fresh matcher per call: a shared /g regex carries `lastIndex` between callers, which is a defect that only appears once there is a second caller — and this file is the second caller. The history it closes. R-291 measured the two apart: `tagsIn` was widened to tolerate case and spacing around the colon, this file's strict half was not, and `[cus: Spot — x]` was then READ by one guard and reported unparseable by the other. The build failed, which is the safe direction, but it failed saying the tag could not be read while another guard had just read it. Widened to match; `beatLikeIn` below still refuses `[CUS Spot]` and `[CUS= Spot]`, which neither strict reader accepts, so the canary keeps its teeth. check-behaviour asserts the two readers agree — that assertion is the thing that must not be deleted, not this comment. */ const TAG_BODY = String.raw`([^\]—]+?)\s*(?:—|\])`; const TAG = () => tagRe(TAG_BODY); 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(TAG())) { 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(TAG())].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(""); }