/** * A figure a scenario prints must still be the figure its artifact holds. * * check-firstblood and check-attackers both compare a baseline against a fresh run, so * they catch the GAME changing. Neither of them reads the document. Re-record after a * re-cast and the artifact updates, the guard goes green, and the paragraph goes on * printing the old number with a citation under it saying where the new one lives. The * citation makes it worse, not better: it tells a GM the figure is checked. * * So the citations are machine-readable. A figure copied out of an artifact is written * * a swing of **40** * * and this guard resolves every one of them against the named JSON. Copy a number wrong, * or re-record and leave the prose alone, and the build stops with both values named. * * This is the check-bestiary arrangement one size down. That document is GENERATED from * the game, so it cannot drift; a scenario is written by hand and cannot be, but the * numbers inside it can be held to the same standard. * * node tools/check-cited.mjs check every citation in every scenario * node tools/check-cited.mjs --list print what each one currently resolves to */ import { readFileSync, existsSync } from "node:fs"; import path from "node:path"; import { scenarioFiles } from "./check-scenarios.mjs"; import { step5Split } from "./step5-split.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"); /* Artifact name as written in a citation -> where the figure comes from. Adding an entry here is what makes a figure citeable; nothing else in the document needs to know. TWO KINDS OF SOURCE, because there are two kinds of figure and they fail differently. A STRING is a recorded baseline: a sampled measurement with a seed, a run count and noise around it. Re-running is expensive, so the number is stored and `--update` re-records it. The failure such a citation catches is "somebody re-recorded and left the prose behind". A `{ derive }` is computed on this build. It is for a figure that is a pure function of rules.mjs — enumerated, exact, no seed and no runs. Storing one would be a cache of the rule, and a cache can go stale; worse, it would arrive with an `--update` able to silence a real disagreement between the document and the game. So nothing is stored, there is nothing to update, and the failure it catches is the one that actually happened three times to the same number: the prose is wrong about the rule. The mechanism is identical either way, which is the point. The document writes `**48.1%**` and does not care which kind it is. */ 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", "outcomes": "tools/outcome-coverage-baseline.json", "packs": "tools/pack-tables-baseline.json", "detention": "tools/detention-baseline.json", "step5": { derive: step5Split, label: "tools/step5-split.mjs, enumerated on this build" } }; const isDerived = name => typeof ARTIFACTS[name] === "object"; const sourceOf = name => isDerived(name) ? ARTIFACTS[name].label : ARTIFACTS[name]; /* Fields that exist in an artifact but must never appear in prose. A sample maximum is the clearest case: same party, same seeds, same runs, and renaming a config moved fight-tail's `longest` from 71 to 90 rounds, because the stream is derived from the id. It reads like a bound on the encounter and is a property of the label. Citing it would be the n=1 mistake one level up, so the citation itself is refused. */ const UNCITEABLE = { longest: "a sample maximum, not a bound — it moves by a third of its value on a re-seed. " + "Cite p99 instead: 'one fight in a hundred runs past N rounds' is stable to about three rounds." }; const loaded = new Map(); function artifact(name) { if (loaded.has(name)) return loaded.get(name); const entry = ARTIFACTS[name]; if (!entry) return null; /* Derived once per run, not once per citation: a dozen citations against one enumeration should cost one enumeration. */ if (isDerived(name)) { const value = entry.derive(); loaded.set(name, value); return value; } const file = path.join(ROOT, entry); const json = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; loaded.set(name, json); return json; } /** "configs.cut.swing" or "cut.swing" — the leading container is optional. */ function resolve(json, dotted) { const direct = dotted.split(".").reduce((o, k) => (o == null ? o : o[k]), json); if (direct !== undefined) return direct; for (const container of ["configs", "agents"]) { const v = dotted.split(".").reduce((o, k) => (o == null ? o : o[k]), json?.[container]); if (v !== undefined) return v; } return undefined; } /* The number immediately before the marker, allowing for markup and a trailing unit: "**40**", "25.5%", "1.4x". Anchored to the end so it is the nearest one. [ \t] rather than \s throughout, so a citation cannot span a newline. The failure text below has always promised the comment must follow its figure "immediately, with nothing but markup between"; \s* quietly allowed a marker on the following line, so the code was looser than its own documentation. Making them agree also keeps the offender report honest — see the count check. */ const CITE = /([\d]+(?:\.[\d]+)?)[ \t]*(?:%|x)?\**[ \t]*(?:—|-|–)?[ \t]*/gi; /* ANY marker a person might have meant as a citation, however spelled or placed. The two patterns are deliberately ASYMMETRIC: the reader accepts only what the format specifies, the counter recognises anything citation-shaped, and the difference is reported. Both were once the same literal, and that made the count check blind in exactly the way it existed to prevent — neither carried `i`, so ``, `` and `` were invisible to the reader AND to the counter watching for invisible markers. A citation printing 41 against an artifact holding 40 passed under all three, reporting OK with a count identical to a clean tree. Capitalising the first word of a comment is not an exotic mistake; it is what a person does without thinking. CITE now tolerates case and a space around the colon, so those spellings simply work. ANY_CITE stays looser still, so a spelling neither of us anticipated is counted and named rather than skipped. */ const ANY_CITE = //gi; let checked = 0; const problems = []; const rows = []; /* Deliberately NOT scenarioText(): that strips HTML comments, which is where the citations live. The raw file is the thing being checked. */ for (const [rel, abs] of scenarioFiles()) { const text = readFileSync(abs, "utf8"); /* A MARKER THIS GUARD CANNOT READ IS WORSE THAN NO MARKER AT ALL, because whoever wrote it believes they added a check. CITE requires the comment to sit immediately after its number; put one word between them and the citation is silently skipped while the run still reports OK with the same count as a clean tree. Found by a peer session writing a bad citation by accident. So every occurrence is counted and the totals must agree. */ const markers = [...text.matchAll(ANY_CITE)]; const spans = [...text.matchAll(CITE)].map(m => [m.index, m.index + m[0].length]); /* Located by position, not per line: a valid citation is a marker some CITE match covers. The first version of this filtered line by line, which named any VALID citation that happened to share a line with nothing — reporting a working marker as broken whenever another one was genuinely unread. The count was right and the explanation was wrong, which is the defect this guard exists to prevent, two levels down. */ const unread = markers.filter(m => !spans.some(([a, b]) => m.index >= a && m.index < b)); if (unread.length) { const lineOf = i => text.slice(0, i).split("\n").length; problems.push(` ${rel}: ${unread.length} citation marker(s) this guard cannot read, so ` + `nothing checks them — a marker that looks like a citation and is never read is the ` + `defect this guard exists for. Either it is misplaced (the comment must follow its ` + `figure immediately, \`**40**\`, on the same line with ` + `nothing but markup between) or it is misspelled (case and spaces around the colon ` + `are fine; the shape \`cite: \` is not optional).`); unread.forEach(m => { const n = lineOf(m.index); problems.push(` line ${n}: ${text.split("\n")[n - 1].trim().slice(0, 110)}`); }); } for (const m of text.matchAll(CITE)) { const [, printed, name, dotted] = m; checked++; const json = artifact(name); if (!json) { problems.push(` ${rel}: cites "${name}", which is not a known artifact ` + `(${Object.keys(ARTIFACTS).join(", ")})`); continue; } const held = resolve(json, dotted); if (held === undefined) { problems.push(` ${rel}: cites ${name} ${dotted}, which that artifact does not hold`); continue; } const leaf = dotted.split(".").pop(); if (UNCITEABLE[leaf]) { problems.push(` ${rel}: cites ${name} ${dotted}, which must not be quoted — ${UNCITEABLE[leaf]}`); continue; } rows.push([rel, `${name} ${dotted}`, printed, String(held)]); if (Number(printed) !== Number(held)) { problems.push(isDerived(name) ? ` ${rel}: prints ${printed} but the game gives ${held} (${dotted}, from ` + `${sourceOf(name)}). Nothing was re-recorded and nothing can be — this figure is ` + `enumerated from rules.mjs on every build, so the document is wrong about the ` + `rule. Fix the sentence, or change the rule and watch the number follow.` : ` ${rel}: prints ${printed} but ${sourceOf(name)} holds ${held} ` + `(${dotted}). Re-recording updated the artifact and left the prose behind — fix the sentence.`); } } } if (LIST) { for (const [f, src, printed, held] of rows) { console.log(` ${f.padEnd(32)} ${src.padEnd(28)} prints ${printed.padStart(6)} holds ${held}`); } process.exit(0); } /* A guard that passes because it found nothing to check is the defect this suite has now been caught by four times, so it is not allowed here: the citations exist, and a run that cannot see them means the markers were reformatted away, not that the prose is clean. */ if (!checked) { console.error("check-cited: FAILED — no cited figures found at all. The scenarios carry " + "`` markers next to every measured number; finding none " + "means they have been stripped or reformatted, not that there is nothing to check."); process.exit(1); } if (problems.length) { console.error("check-cited: FAILED — a scenario's cited figures do not match their artifacts"); problems.forEach(p => console.error(p)); process.exit(1); } const artifacts = new Set(rows.map(r => r[1].split(" ")[0])); console.log(`check-cited: OK — ${checked} cited figures across ${scenarioFiles().length} scenario files ` + `resolve against ${artifacts.size} baselines`);