c0 argued a baseline for the step-5 split would be a cache of the rule and a guard
over it would mostly assert that arithmetic has not changed. Right objection,
wrong conclusion: do not store it. ARTIFACTS now takes a { derive } entry as well
as a file path -- enumerated on this build, nothing stored, and no --update able
to silence a real disagreement between the document and the game.
tools/step5-split.mjs enumerates all 10,000 pairs through opposedContestFor with
its targets derived: 55 and 60 are the lowest POWx5 in the six and in the cut, 42
is applyDifficulty(85, "difficult"). Two different rules produce those three
numbers -- the accepted row is a named exception, not the lowest of anything -- and
a test fails if anyone unifies them. A tie in "the lowest POW in the room" is
fatal rather than silently resolved; it fired for real in testing.
Proved four ways, including raising Okonkwo's POW to 13: the lowest moves to
Braithwaite, refused.held goes 48.1 to 52.4, and the citation that was correct a
moment earlier fails. The figure follows the rule.
Landed unused -- the markers are c0's to place in their own file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
11 KiB
JavaScript
220 lines
11 KiB
JavaScript
/**
|
||
* 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**<!-- cite: first-blood cut.swing -->
|
||
*
|
||
* 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%**<!-- cite: step5 refused.held -->` 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",
|
||
"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]*<!--[ \t]*cite[ \t]*:[ \t]*([\w-]+)[ \t]+([\w.]+)[ \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 `<!-- Cite: ... -->`, `<!-- CITE: ... -->`
|
||
and `<!-- cite : ... -->` 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 = /<!--[ \t]*cite[ \t]*:[^>]*-->/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**<!-- cite: artifact path -->\`, on the same line with `
|
||
+ `nothing but markup between) or it is misspelled (case and spaces around the colon `
|
||
+ `are fine; the shape \`cite: <artifact> <dotted.path>\` 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 "
|
||
+ "`<!-- cite: artifact path -->` 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`);
|