diff --git a/docs/scenarios/CLEAN_GROUND.md b/docs/scenarios/CLEAN_GROUND.md index 5195504..229272b 100644 --- a/docs/scenarios/CLEAN_GROUND.md +++ b/docs/scenarios/CLEAN_GROUND.md @@ -9,10 +9,10 @@ | | | |---|---| -| **Version** | **v0.17**, 13 September 2026 | +| **Version** | **v0.18**, 13 September 2026 | | **Can it be run?** | **Yes, from this document alone.** A GM with this file and the compendium can run it end to end. | | **Is it convention-ready?** | **Nearly.** The print pack is built (`CLEAN_GROUND_HANDOUTS.html`, held by `check-handouts`). What is missing is a run with human beings, and a slot. | -| **Playtesting** | **Seven desk passes, no human runs.** `_1.md` GM chair (seed 4271) · `_2.md` Act Four stress (9314) · `_3.md` player chair, contrarian (5580) · `_4.md` **the clock, against v0.13's recut timings** (6142) · `_5.md` **the four-player cut, played** (8815) · `_6.md` **the four untested branches, forced** (3319) · `_7.md` **an honest session, nothing forced** (7742). Forty-five fixes plus three post-passes. Pass 7 ran ~3:35 against 3:40 at six players with no cuts taken — the first honest pass inside the clock unaided. | +| **Playtesting** | **Seven desk passes, no human runs.** `_1.md` GM chair (seed 4271) · `_2.md` Act Four stress (9314) · `_3.md` player chair, contrarian (5580) · `_4.md` **the clock, against v0.13's recut timings** (6142) · `_5.md` **the four-player cut, played** (8815) · `_6.md` **the four untested branches, forced** (3319) · `_7.md` **an honest session, nothing forced** (7742). Forty-seven fixes plus three post-passes, and pass 7's finding is now a guard: **`check-outcomes` reads every bracketed mechanic tag in the acts and holds what its beat states.** Pass 7 ran ~3:35 against 3:40 at six players with no cuts taken — the first honest pass inside the clock unaided. | | **What it is** | A **convention one-shot**, decided 13 September 2026. It ends in the slot; there is no second session and the text must not promise one. | | **Where it runs** | **Contingency 2027 — Sunday 31 January 2027, afternoon** (slot 9), Searles Leisure Resort, Hunstanton. | | **Runtime** | **3h30 of play plus a 10-minute break — 3h40 wall clock**, which is the house budget. Act Two was cut from 65 minutes to 45 in v0.13 to get there. The **Pacing Note** carries a further 65 minutes of cuts; take them as the default. | @@ -82,10 +82,14 @@ premise (a walk north that never arrives) and the predator's method (imitation). - **System:** d100 roll-under. Special under a fifth, critical under a twentieth. 96–99 never succeed; 00 always fumbles. - ⚠ **A SPECIAL IS COMMONER THAN A FUMBLE AND THE TEXT WRITES FEWER OF THEM.** At a rating - of 63 a special or better is **13%** and a fumble **2%**; at 53, **11%** against 3%. Desk - pass 7 enumerated it: **90.8% of sessions contain a special or critical on a beat with no - written case.** Where one is written the shape is always the same — the good roll buys - something **portable, private or early**, never more conversation. Improvise to that shape. + of 63 a special or better is **13%** and a fumble **2%**; at 53, **11%** against 3%. Across + the case's **21** rollable beats, a session hits a special on + a beat with nothing written **85%** of the + time, and an unwritten fumble **30.6%**. + **`check-outcomes` holds those figures and ratchets them** — coverage may improve and may + not quietly go backwards — so they are measured from the acts rather than typed here. + Where a special is written the shape is always the same: the good roll buys something + **portable, private or early**, never more conversation. Improvise to that shape. - ⚠ **A fumble is the top twentieth of the FAILURE range, not just 00** — and that band widens as the skill falls. **00 at 85, 99–00 at 63, 98–00 at 53, and 97–00 at 40 or below.** Desk pass 6 found this document implying 1% throughout; at Spot 40 it is **4%**, @@ -727,6 +731,14 @@ agents got what they wanted. journey she has never completed. - The six **hollow men** walk at the back. The column is kind to them and does not talk about them. `[CUS: Spot — the same six at the back, and the same six yesterday]` + *Missed:* Ivy says it herself when she trusts them, in the flat voice of somebody who + stopped finding it strange a long time ago — *"they don't eat, love."* The clue is not + gated; the roll only buys noticing it before she has to say it. + - **On a special:** not only the same six, but the same six *in the same order*, and one + of them is walking in somebody else's boots. + - **On a fumble (98–00 at Pollard's 53, 97–00 at Spot 40):** the agent counts seven, and + is certain. There is no seventh. **Do not resolve this** — let them look again later + and find six, and let that sit. - Somewhere out on the flank, something is keeping pace at a distance and on all fours. - **If violence against the column is even possible at your table, read EXPOSURE in Act Four first.** Six of them wipe one party in seven; at four players three of them wipe @@ -843,6 +855,13 @@ through with him. and there is nothing wrong with him.* The absence **is** the information. A player who rolled well is owed that sentence rather than a shrug — desk pass 1 flagged this as feeling like a bug from the player's chair when it is in fact the thesis. + *Missed:* **the same nothing, and that is the honest answer** — say *you can't read him* + and let the player decide whether that is the roll or the man. **This is the one beat in + the case where a success and a failure are indistinguishable at the table**, and it is + deliberate. Do not invent a difference to make the roll feel worth making. + - **On a critical (01–04 at 63):** he is word-perfect *and he is not tired*. Thirty years + of rehearsal and no strain in it anywhere — which is the first thing about him that is + wrong, arrived at through the absence rather than around it. - `[CUS: Psychology — it is not pretending to be human; it thinks it is the file]` *Missed:* this line is far too good to lose to one 40% roll, and desk pass 2 lost it. If it is failed or never attempted, **it surfaces in beat 3 instead** — the thing says it diff --git a/docs/scenarios/CLEAN_GROUND_PLAYTEST_7.md b/docs/scenarios/CLEAN_GROUND_PLAYTEST_7.md index cfe4f00..0ed55cf 100644 --- a/docs/scenarios/CLEAN_GROUND_PLAYTEST_7.md +++ b/docs/scenarios/CLEAN_GROUND_PLAYTEST_7.md @@ -270,3 +270,47 @@ Caught while applying the fix, by reading the surrounding bullet instead of the finding named. **The error is this project's most familiar one in a new place** — a claim about a document checked against the quoted fragment rather than against the document. The finding survives at its proper size and is fixed in v0.17 along with the other two. + +--- + +## POST-PASS 2 — the counts in this pass were hand-made, and two of them were wrong + +Building `check-outcomes` off finding 1 measured what the finding had counted by hand, and +the machine disagreed: + +| | This pass said | Measured | +|---|---|---| +| Rollable beats | 22 | **21** | +| Failure cases | 17 | 16 | +| Fumble cases | 4 | 5 | +| Special or critical | 3 | 4 | + +**The beat count was wrong for a reason worth keeping.** Act Three's warning *quotes* +`[CUS: Anomaly Lore — what a peg is]` to say which die fires its twenty-minute bomb — the +fix this pass's predecessor asked for — and a hand count reading down the page counts the +quotation as a beat. That is R-286 exactly, in the other direction: `declared-cast.mjs` +blanks code spans because a declaration inside backticks is a mention, and here every beat +*lives* inside backticks, so the thing to exclude is the blockquote. The reader skips it and +says so. + +The other three moved because **v0.17 changed them** — it added fumbles and specials in +response to this document — so the figures were stale within one commit of being written. +Which is the argument the peer session made for putting them in an artifact, and it was +right: the three figures now carry citation markers against `outcome-coverage-baseline.json`, +so the prose cannot drift from the acts without `check-cited` saying so. + +**Writing that sentence was the third instance of the defect in twenty minutes.** The draft +spelled the marker out, and `check-cited`'s own unreadable-marker counter refused it — a +citation-shaped comment in prose about citations. Before that, the STATUS line announcing +`check-outcomes` contained a beat-shaped tag and the new guard counted it as a beat. Before +that, R-286 found the cast declaration and a mention of one identical to a regex. **Three +readers, three authors describing their own format inside it, and each one caught by a guard +built for a different pass.** The lesson is not to write more carefully; it is that an +asymmetric counter — something that flags what the strict reader could not parse — earns its +keep faster than anything else in this suite. + +**The finding itself is unchanged and was, if anything, understated**: 16 of 21 beats stated +a failure and 3 stated a special, against 88.4% of sessions hitting an unwritten special at +the time this pass was written. After v0.17 and the two bare beats filled in while building +the guard, that is 85% — still the single largest uncovered surface in the document, and now +the only one with a ratchet under it. diff --git a/package.json b/package.json index 34ef9a6..6a13f47 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "play": "node tools/playthrough.mjs", "mj": "node tools/mj-queue.mjs", "simulate": "node tools/simulate.mjs", - "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-cited.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", + "check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-cited.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", "test": "bun run check", "readme": "bun tools/update-readme.mjs" }, diff --git a/tools/check-cited.mjs b/tools/check-cited.mjs index 8ecf367..d12d58e 100644 --- a/tools/check-cited.mjs +++ b/tools/check-cited.mjs @@ -34,7 +34,8 @@ const ARTIFACTS = { "first-blood": "tools/first-blood-baseline.json", "attackers": "tools/attackers-baseline.json", "fight-tail": "tools/fight-tail-baseline.json", - "lethality": "tools/lethality-baseline.json" + "lethality": "tools/lethality-baseline.json", + "outcomes": "tools/outcome-coverage-baseline.json" }; /* Fields that exist in an artifact but must never appear in prose. A sample maximum is diff --git a/tools/check-outcomes.mjs b/tools/check-outcomes.mjs new file mode 100644 index 0000000..d735a87 --- /dev/null +++ b/tools/check-outcomes.mjs @@ -0,0 +1,150 @@ +/** + * A beat that names a roll must say what the roll DOES — and this holds the claim. + * + * check-rollable asks whether a skill is reachable at 25% by somebody in the declared cast. + * Desk pass 5 found that is rollability and not competence. Desk pass 7 found it is also not + * COVERAGE: a beat can be reachable, well-rated, and silent about every outcome but the one + * the GM improvises, and every guard in the suite stays green. Sixteen of twenty-one beats + * stated a failure case and only three a special, while a special is 13% at a rating of 63 + * and the case asks for twenty-one rolls. + * + * node tools/check-outcomes.mjs hold the claim + * node tools/check-outcomes.mjs --list print what it found + * node tools/outcome-coverage.mjs --update re-record after deliberately changing it + * + * TWO KINDS OF CHECK, AND THE DIFFERENCE IS THE POINT. + * + * RATCHET — coverage may improve and may not regress; `--update` re-records it. This is + * check-focus's reliability ratchet in another place: the figures move as the document is + * written, and freezing them exactly would make every improvement a failure. + * + * HARD CLAIMS — read from what is measured NOW and never from the baseline, so `--update` + * cannot silence them. R-270 put two claim checks in check-fight-tail for this reason and + * they are the only part of that guard that cannot be argued with. A guard whose every + * assertion can be re-recorded is a record of what happened, not a check. + * + * ITS FIRST CATCH WAS ITS AUTHOR. The STATUS line written in the same commit said this + * guard "reads every [CUS: ...] beat" — inside backticks, which is where real beats live — + * and the reader counted the sentence about beats as a beat. The `...` is not a skill anybody + * in the cast has a rating for, so the unrated claim below fired as well and named it twice. + * Nothing was changed in the reader: prose that is shaped like a beat IS the thing this file + * is for, and a fenced block is the place to write one where it will not be read. + * + * THE VACUITY FLOOR IS NOT DECORATION. check-cited shipped passing with zero citations + * found: the corpus regex was wrong, nothing matched, and "all figures resolve" was true of + * an empty set. If the beat regex ever stops matching, this file must go red rather than + * report full coverage of nothing. + */ +import { readFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { measure, beatsIn, bestRatings, BASELINE } from "./outcome-coverage.mjs"; +import { SCENARIO } from "./declared-cast.mjs"; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); +const LIST = process.argv.slice(2).includes("--list"); + +/* The document has had twenty-one beats since Act Two was recut and has never had fewer. + A real re-cut that drops below this should fail once, loudly, and be re-recorded on + purpose — which is the whole difference between a floor and a baseline. */ +const MIN_BEATS = 18; +/* R-287: a scope that loses its end becomes the file. Nothing here should read a tenth of + the document to decide what one beat says. */ +const MAX_SCOPE_SHARE = 0.05; + +if (!existsSync(BASELINE)) { + console.error(`check-outcomes: FAILED — no baseline at ${path.relative(ROOT, BASELINE)}. ` + + `Record one with: node tools/outcome-coverage.mjs --update`); + process.exit(1); +} +const base = JSON.parse(readFileSync(BASELINE, "utf8")); +const now = measure("check-outcomes"); +const fileChars = readFileSync(SCENARIO, "utf8").length; +const problems = []; +const notes = []; + +/* ── HARD CLAIMS — every one of these reads `now`, never `base` ─────────────────────── */ + +if (now.beats < MIN_BEATS) { + problems.push(` only ${now.beats} beats found, and this document has at least ${MIN_BEATS}. ` + + `Either the acts lost a third of their rolls, or the \`[CUS: ...]\` reader stopped matching ` + + `and this guard is about to report full coverage of nothing — which is exactly how ` + + `check-cited once passed with no citations at all.`); +} else { + notes.push(`${now.beats} beats found, ${now.quotedSkipped} blockquote quotation skipped`); +} + +if (now.bare.length) { + problems.push(` ${now.bare.length} beat(s) name a roll and state NO outcome at all — ` + + `${now.bare.join(", ")}. A GM who reaches one has a die in their hand and nothing on the ` + + `page. Say what a miss gives, even when the honest answer is "the same nothing". ` + + `IF ONE OF THESE IS AN EXAMPLE RATHER THAN A BEAT, put it in a fenced block: beats live ` + + `inside backticks, so backticks cannot be the safe harbour here the way they are for a ` + + `cast declaration, and a fenced block is where this reader stops looking.`); +} + +const unbounded = beatsIn(readFileSync(SCENARIO, "utf8")).filter(b => !b.quoted && !b.bounded); +if (unbounded.length) { + problems.push(` ${unbounded.length} beat scope(s) ran to the end of the file without finding ` + + `a structural end (${unbounded.map(b => `${b.skill}:${b.line}`).join(", ")}). R-287: a scope ` + + `that cannot find its end does not shrink or fail, it becomes the document, and then this ` + + `guard reads every outcome in the case as belonging to one beat.`); +} +if (now.widestScope > fileChars * MAX_SCOPE_SHARE) { + problems.push(` the widest beat scope is ${now.widestScope} characters, over ${Math.round(MAX_SCOPE_SHARE * 100)}% ` + + `of a ${fileChars}-character document. One beat should not need a twentieth of the case to ` + + `say what it does; the block boundaries have probably stopped working.`); +} else { + notes.push(`widest scope ${now.widestScope} chars of ${fileChars} — blocks end where they should`); +} + +if (now.unparsed) { + problems.push(` ${now.unparsed} tag(s) look like \`[CUS: ...]\` and this reader cannot parse them. ` + + `A mechanic tag nothing reads is worse than none, because whoever wrote it believes the ` + + `beat is tagged. Spell them \`[CUS: Skill — what it gets]\`.`); +} +if (now.unrated.length) { + problems.push(` ${now.unrated.length} beat(s) name a skill nobody in the declared cast has a ` + + `rating for — ${now.unrated.join(", ")}. Those beats are silently dropped from every ` + + `probability below, so the coverage figures would be measured over a smaller case than ` + + `the one being run. check-rollable owns whether the skill is reachable; this owns whether ` + + `it was counted.`); +} + +/* ── RATCHET — coverage may improve, never regress ──────────────────────────────────── */ + +for (const band of ["failure", "fumble", "special"]) { + if (now.stated[band] < base.stated[band]) { + problems.push(` beats stating a ${band} fell from ${base.stated[band]} to ${now.stated[band]}. ` + + `Coverage is a ratchet here: it may be improved and re-recorded, and it may not quietly ` + + `go backwards. If a beat was deliberately removed, re-record with --update and say so ` + + `in the commit.`); + } +} +for (const [key, label] of [["unwrittenFumble", "fumble"], ["unwrittenSpecial", "special"]]) { + if (now.session[key] > base.session[key] + 0.05) { + problems.push(` the chance a session hits an unwritten ${label} rose from ${base.session[key]}% ` + + `to ${now.session[key]}%. Either a beat lost its ${label} case or the acts gained a roll ` + + `without one.`); + } +} +notes.push(`stated: failure ${now.stated.failure}/${now.beats}, fumble ${now.stated.fumble}, special ${now.stated.special}`); +notes.push(`per session: ${now.session.anyFumble}% fumble (${now.session.unwrittenFumble}% unwritten), ` + + `${now.session.anySpecial}% special (${now.session.unwrittenSpecial}% unwritten)`); + +if (LIST) { + const rating = bestRatings("check-outcomes"); + for (const b of beatsIn(readFileSync(SCENARIO, "utf8")).filter(x => !x.quoted)) + console.log(` ${String(b.line).padStart(5)} ${b.failure ? "fail" : " "} ${b.fumble ? "fum" : " "} ` + + `${b.special ? "spec" : " "} ${String(rating(b.skill) ?? "—").padStart(4)} ${b.skill}`); + notes.forEach(n => console.log(` ${n}`)); + problems.forEach(p => console.log(p)); + process.exit(0); +} +if (problems.length) { + console.error("check-outcomes: FAILED — a beat names a roll and does not say what it does"); + problems.forEach(p => console.error(p)); + process.exit(1); +} +console.log(`check-outcomes: OK — ${now.beats} beats, ${now.stated.failure} state a failure, ` + + `${now.stated.fumble} a fumble, ${now.stated.special} a special; ${now.session.unwrittenSpecial}% of ` + + `sessions hit a special with nothing written, down from ${base.session.unwrittenSpecial}% recorded`); diff --git a/tools/outcome-coverage-baseline.json b/tools/outcome-coverage-baseline.json new file mode 100644 index 0000000..a2976fc --- /dev/null +++ b/tools/outcome-coverage-baseline.json @@ -0,0 +1,19 @@ +{ + "beats": 21, + "quotedSkipped": 1, + "unparsed": 0, + "bare": [], + "unrated": [], + "widestScope": 1229, + "stated": { + "failure": 18, + "fumble": 6, + "special": 6 + }, + "session": { + "anyFumble": 41, + "unwrittenFumble": 30.6, + "anySpecial": 93, + "unwrittenSpecial": 85 + } +} diff --git a/tools/outcome-coverage.mjs b/tools/outcome-coverage.mjs new file mode 100644 index 0000000..baea19e --- /dev/null +++ b/tools/outcome-coverage.mjs @@ -0,0 +1,179 @@ +/** + * 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(""); +}