diff --git a/docs/scenarios/CLEAN_GROUND.md b/docs/scenarios/CLEAN_GROUND.md index dbe256e..b0ac04a 100644 --- a/docs/scenarios/CLEAN_GROUND.md +++ b/docs/scenarios/CLEAN_GROUND.md @@ -9,10 +9,10 @@ | | | |---|---| -| **Version** | **v0.22**, 13 September 2026 | +| **Version** | **v0.23**, 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** | **Eleven 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) · `_8.md` **the cold read, plus an honest session** (5108) · `_9.md` **the regression pass, every fix from 4–8 re-checked** · `_10.md` **the Close, and a twelve-seed census** (4417) · `_11.md` **the fight the players choose, and what it leaves** (7311). Sixty-five fix-list items and nine 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. **Pass 11 closed the last desk-reachable branch; the only untested thing left is a run with human beings.** | +| **Playtesting** | **Eleven 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) · `_8.md` **the cold read, plus an honest session** (5108) · `_9.md` **the regression pass, every fix from 4–8 re-checked** · `_10.md` **the Close, and a twelve-seed census** (4417) · `_11.md` **the fight the players choose, and what it leaves** (7311). Sixty-five fix-list items and nine post-passes, and two passes' findings are now guards: **`check-outcomes` reads every bracketed mechanic tag in the acts and holds what its beat states**, and **`check-figures` reads the figures rather than the markers, so a measured number printed with nothing holding it stops the build.** Pass 7 ran ~3:35 against 3:40 at six players with no cuts taken — the first honest pass inside the clock unaided. **Pass 11 closed the last desk-reachable branch; the only untested thing left is a run with human beings.** | | **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. | @@ -1066,7 +1066,7 @@ whoever it is copying, always. > punch that dies in two rounds — do not buff it. Six of the column kill one party in > seven; ten kill nine in ten. **The six at the back are never fought alone**: two of the > column joining in takes that fight from nobody dying to one wipe in twelve, and **at -> four players the six at the back wipe 74.9% on their own.** +> four players the six at the back wipe **74.9%** on their own.** > > **A fight also costs the session** — a median 13 rounds, > **21** if two of the column join, which is most of an act. **So there is no version of a fight here that is good for anybody**, and @@ -1084,10 +1084,12 @@ encounter in the case that is incapable of being dangerous. > measured through `simulate.mjs`'s CLI, which calls `runFight` with no options — so it ran > at the default **40-round ceiling**, and `runFight` does not report truncation: it scores > whoever is standing when the loop stops. **Six hollow men against the four-player cut hit -> that ceiling in 578 runs of 2000, and the party's real wipe rate is 74.9%, not the 58.7% +> that ceiling in 578 runs of 2000, and the party's real wipe rate is **74.9%**, not the 58.7% > this document printed for three versions.** Fourteen points, on the most alarming number -> in the case. Everything here is now measured at a 400-round cap where nothing truncates -> (the longest fight is 120 rounds), recorded in `tools/pack-tables-baseline.json`, and held +> in the case. Everything here is now measured at a 400-round cap, and **`check-packs` refuses +> to record a sweep in which anything reached it** — a stronger guarantee than quoting a +> longest fight, because a sample maximum moves by a third of its value on a re-seed. +> Recorded in `tools/pack-tables-baseline.json`, and held > by **`check-packs`** against the game and by **`check-cited`** against this prose. The > figures carry citation markers; they are no longer typed. diff --git a/package.json b/package.json index 80bbd12..9e6de08 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-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-packs.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-packs.mjs && bun tools/check-cited.mjs && bun tools/check-figures.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-figures.mjs b/tools/check-figures.mjs new file mode 100644 index 0000000..3f49fab --- /dev/null +++ b/tools/check-figures.mjs @@ -0,0 +1,234 @@ +/** + * A figure nothing holds is a figure that drifts, and the guard that would catch it cannot + * see it. + * + * `check-cited` resolves every citation marker in the corpus against the artifact it names. + * It is a good reader and it has exactly one blind spot, which is structural rather than a + * bug: it can only resolve the markers that exist. A measured figure written into prose with + * NO marker beside it is not a failed citation — it is not a citation at all, and nothing + * looks at it ever again. + * + * That is not hypothetical. Desk pass 11 found "a median 24 rounds" in the Pacing Note and + * "24 if the column joins" in EXPOSURE. The baseline holds 21 at two of the column joining + * and 25 at three. 24 is neither, it had been wrong through every pass that checked + * citations, and it survived because it carried no marker to check. The same document + * printed its four-player wipe rate uncited twice while citing it correctly four times — + * and that figure is the one that was published at 58.7% until a truncated sweep was found. + * + * So this is the asymmetric counter for the other direction, and the fifth in this repo: + * + * check-cited reads markers -> is the figure beside this marker right? + * check-figures reads FIGURES -> does this figure have a marker at all? + * + * THE OPT-IN RULE. A document that uses citations has opted into the regime, and every + * measurement-shaped figure in it must carry a marker. A document with no markers anywhere + * has not, and its figures are counted and held by a ratchet rather than failing the build — + * because turning three unguarded scenarios red is how a guard gets switched off on the day + * it is written. The ratchet still means they cannot get worse, and the moment such a + * document gains its first citation it joins the strict regime in full. + * + * node tools/check-figures.mjs check the corpus + * node tools/check-figures.mjs --list print every figure found, marked or not + * node tools/check-figures.mjs --update re-record the ratchet for opted-out documents + */ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import path from "node:path"; +import { playableFiles } from "./check-scenarios.mjs"; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); +const BASELINE = path.join(ROOT, "tools", "figures-baseline.json"); +const argv = process.argv.slice(2); +const LIST = argv.includes("--list"); +const UPDATE = argv.includes("--update"); + +/* A number, with the document's own bolding allowed around it. */ +const N = String.raw`\*{0,2}(\d+(?:\.\d+)?)\*{0,2}`; + +/* THE SHAPES A HARNESS FIGURE TAKES IN THIS CORPUS. + Deliberately narrow. An earlier draft matched any number within forty-five characters of + a measurement word and reported 282 candidates, nearly all of them prose — "down 140 + steps", "an engineer on his rounds", "01:06". A guard that cries wolf 282 times is a + guard nobody runs. These are the phrasings the documents actually use when quoting the + simulator, and each one was read off a figure that IS cited somewhere. */ +const SHAPES = [ + ["median", new RegExp(String.raw`median(?:\s+of)?\s+` + N, "gi")], + ["rounds", new RegExp(N + String.raw`\s*\*{0,2}\s*rounds\b`, "gi")], + ["wiped", new RegExp(N + String.raw`\s*%?\s*\*{0,2}\s*(?:wiped|wipes)\b`, "gi")], + ["wipes-pct", new RegExp(String.raw`wipe[sd]?\s+` + N + String.raw`\s*%`, "gi")], + ["wipe-rate", new RegExp(String.raw`wipe rate[^.]{0,24}?` + N + String.raw`\s*%`, "gi")], + ["of-N-hurt", new RegExp(N + String.raw`\s*\*{0,2}\s*of\s+\d+\s+(?:hurt|down)\b`, "gi")], + ["deaths", new RegExp(N + String.raw`\s*\*{0,2}\s*deaths?\b`, "gi")], + ["pct-of-runs", new RegExp(N + String.raw`\s*%[^.]{0,18}\bruns?\b`, "gi")] +]; + +/* WHAT IS NOT A MEASUREMENT, each with the case that put it here. + + A DENOMINATOR. "one party in 300 wiped" is a rate written longhand; 300 is the bottom of + a fraction, not a sample statistic, and THROUGH_TRAIN writes rates that way throughout. + + A THRESHOLD. "Past 15 rounds" is a column header in fight-tail's table — it names the + bucket the figures below are counted into, and it comes from the tool's own definition + rather than from a run. Same for "1 in 100 runs past". + + BOTH ARE TESTED AGAINST THE CONTEXT, NOT THE MATCH. The first draft tested the matched + text alone, which cannot work: the `rounds` shape captures "15 rounds" and the word that + excuses it — "Past" — is outside the match. It reported a column header as an unheld + figure, which is the false positive that makes a guard get switched off. */ +const NOT_A_MEASUREMENT = [ + [/\b(?:in|of)\s+\*{0,2}\d+(?:\.\d+)?\*{0,2}\s*%?\s*\*{0,2}\s*(?:wiped|wipes)\b/i, "a denominator — 'one party in N wiped' is a rate written longhand"], + [/\b(?:past|beyond|over|under|at least|fewer than)\s+\*{0,2}\d+(?:\.\d+)?\*{0,2}\s*(?:%|\s*rounds?)?\s*$/i, "a threshold, not a sample — it names a bucket, not a result"] +]; + +/* FIELDS check-cited REFUSES TO LET ANYBODY CITE, and their values. + `longest` is a sample maximum: same party, same seeds, same runs, and renaming a config + moved one from 71 to 90 rounds, because the stream is derived from the id. check-cited + refuses a citation against it for that reason — but refusing the CITATION while the + document prints the NUMBER leaves the least stable figure in the suite as the only one + nothing holds. So a bare figure whose value matches an uncitable field is reported as its + own kind of problem, with the field named. */ +const UNCITEABLE_FIELDS = ["longest"]; +const uncitableValues = new Map(); +for (const rel of ["tools/pack-tables-baseline.json", "tools/fight-tail-baseline.json"]) { + const abs = path.join(ROOT, rel); + if (!existsSync(abs)) continue; + const walk = (o, trail) => { + if (o && typeof o === "object") { + for (const [k, v] of Object.entries(o)) { + if (UNCITEABLE_FIELDS.includes(k) && typeof v === "number") { + uncitableValues.set(String(v), `${rel} ${[...trail, k].join(".")}`); + } + walk(v, [...trail, k]); + } + } + }; + walk(JSON.parse(readFileSync(abs, "utf8")), []); +} + +/* The marker check, deliberately looser than check-cited's CITE. That reader is strict on + purpose; here, ANY citation-shaped comment beside the figure means somebody marked it and + check-cited owns the question of whether they marked it correctly. Two guards disagreeing + about what counts as marked would leave a figure that neither of them holds. */ +const MARKED_AFTER = /^[ \t]*(?:%|x)?\**[ \t]*(?:—|-|–)?[ \t]*