Applies desk pass 8's five fixes, and adopts R-292's TAG_OPEN. 1. Step 5 now has all three of its outcomes. opposedContestFor returns taken, held and unsettled; the document printed the first and the third. "They hold" is 48.1% against the default target, 52.4% against Braithwaite and 74.3% against an Ashcroft who refused — which v0.17 established is what most tables have, so the middle column is the one they will play. It gets its own countdown row, its own table, and read-aloud of its own, and the COMES TO NOTHING paragraph is now explicitly the unsettled case so it can stop being the nearest text to an outcome it is not about. The reason they held is the case's own argument arriving as good news: a person with a life on the record is a difficult document to overwrite. Every figure re-enumerated over all 10,000 roll pairs before writing. 2. EXPOSURE's five things are six, in order, under a heading that says six. Introduced in my own v0.16 and survived two versions; asserting a unique match protects against editing the wrong text and not against inserting in the wrong place. 3. EXPOSURE opens with a one-minute box. The body is untouched — nothing in it is padding — but 3,176 words is sixteen minutes about the encounter the document exists to prevent, and a GM with thirty minutes of prep now has somewhere to stop. 4. The stale open question is closed. Braithwaite has not been on the critical path since v0.14 un-gated the tell, and pass 6 ran the act with the Xenology and the Psychology both failed. 5. Act Four's Psychology has a special: which file it thinks it is, and how recently it read it. It corrects them on Prichard's service history — it is not remembering, it is citing. Coverage 6 -> 7 specials, so the cited figure moved 85% -> 83.7% and check-cited caught the prose before I did, which is what it is for. R-292 adopted: outcome-coverage now composes its body onto check-scenarios' TAG_OPEN through tagRe, so the two readers cannot drift on what starts a tag while keeping the different bodies they need — and tagRe's fresh matcher per call avoids the shared-lastIndex defect, this file being the second caller that would have found it. Verified identical across all seven spellings. npm run check: 19 guards pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
10 KiB
JavaScript
201 lines
10 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";
|
|
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("");
|
|
}
|