R-291 measured check-scenarios' tagsIn against this file's readers and found them apart on three spellings. tagsIn was widened for case and spacing around the colon; outcome-coverage's strict half still carried the case-sensitive literal, so `[cus: Spot — x]` was 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. beatsIn and beatLikeIn now share one TAG pattern, widened to agree with tagsIn. Measured across seven spellings: the five both strict readers accept now agree in all three, and `[CUS Spot]` and `[CUS= Spot]` are still refused by both and still named by the loose counter, so the canary keeps its teeth. Verified on the real document that this is a spelling fix and nothing else — beats 21, quoted 1, unparsed 0, stated 18/6/6, session figures unchanged, so the baseline is untouched. A lowercase tag planted in the acts is now read by check-outcomes, check-scenarios and check-rollable alike; the file was restored byte-identical after the test. R-291's own assertion still passes. Found by the peer session measuring my file rather than trusting it, which is the fourth reader this session to be caught describing or reading its own format wrongly. npm run check: 19 guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
9.6 KiB
JavaScript
191 lines
9.6 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 */
|
|
|
|
/* ONE PATTERN FOR BOTH HALVES OF THIS FILE, and it agrees with check-scenarios' `tagsIn`
|
|
deliberately. 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 = /\[\s*CUS\s*:\s*([^\]—]+?)\s*(?:—|\])/gi;
|
|
|
|
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("");
|
|
}
|