From 9c2ce9d90527abfa77d7cb85c0c21580072c210c Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:47:25 +0100 Subject: [PATCH] =?UTF-8?q?R-290:=20check-outcomes=20=E2=80=94=20a=20beat?= =?UTF-8?q?=20that=20names=20a=20roll=20must=20say=20what=20it=20does?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/scenarios/CLEAN_GROUND.md | 31 +++- docs/scenarios/CLEAN_GROUND_PLAYTEST_7.md | 44 ++++++ package.json | 2 +- tools/check-cited.mjs | 3 +- tools/check-outcomes.mjs | 150 ++++++++++++++++++ tools/outcome-coverage-baseline.json | 19 +++ tools/outcome-coverage.mjs | 179 ++++++++++++++++++++++ 7 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 tools/check-outcomes.mjs create mode 100644 tools/outcome-coverage-baseline.json create mode 100644 tools/outcome-coverage.mjs 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(""); +}