R-268: guard the sentence that names a player's character

CLEAN GROUND prints that the four-player cut is three effective attackers and
that Ashcroft is not one. Nothing checked it, and a GM reads it aloud to decide
who a real player spends four hours being.

effective-attackers.mjs measures per attack, not per fight: counted per fight
Braithwaite leads on disables, but only because his armour buys him a third more
swings -- per attack he is the weakest of the three. Both rates are recorded and
only the per-attack one is reasoned from. Asserts exact drift, then the sentence:
three clear 5% of attacks disabling, one does not, and that one is Ashcroft.
Re-recording does not silence the claim check; verified in a worktree.

declared-cast.mjs holds the cast marker reading both scenario guards need, rather
than a copy in each. It was briefly named scenario-cast.mjs, which check-scenarios
sweeps into the scenario corpus -- its own example marker was read as a real cast.

update-readme dropped any guard that exited non-zero, so check-rollable vanished
from the README and the count word fell to thirteen while fourteen guards ran. A
missing line is now fatal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 09:56:01 +01:00
co-authored by Claude Opus 5
parent a0d15ad7a9
commit 0b86c5ffc3
9 changed files with 414 additions and 44 deletions
+5 -4
View File
@@ -48,7 +48,7 @@ packs or regenerate art on that machine:
```bash
npm install # pulls classic-level, used to write the LevelDB packs
npm run build # rebuilds packs/ from tools/content.mjs
npm run check # the thirteen guards; the build refuses to run if they fail
npm run check # the fourteen guards; the build refuses to run if they fail
```
To update a deployed server: `git pull` and restart Foundry. If the pull touches
@@ -157,7 +157,7 @@ icons/ fonts/ art/ generated art
packs/ built LevelDB compendia (committed — see Deploying)
tools/
content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs
build-packs.mjs builds packs/ — runs the thirteen guards first and refuses on failure
build-packs.mjs builds packs/ — runs the fourteen guards first and refuses on failure
make-icons.mjs draws all 231 icons
rules-text.mjs generates the rules journal FROM rules.mjs
mission.mjs the case generator
@@ -178,11 +178,11 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a
literal, or if an exported rule has no spot-check. The packs used to be built under
one set of numbers and played under another; this makes that impossible to ship.
The build runs all thirteen guards before it writes anything:
The build runs all fourteen guards before it writes anything:
<!-- guards:start -->
```
check-rules: OK — 110 rules, 40 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified
check-rules: OK — 110 rules, 43 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 1018 keys, no leaf/branch collisions
check-templates: OK — 19 templates compile
@@ -194,6 +194,7 @@ check-anatomy: OK — 5 body plans, 36 locations, d20 complete in both modes, ev
check-lethality: OK — 47 creatures, every one fighting exactly as recorded against the frozen party
check-focus: OK — focus fire measured against 31 packs, helps in all of them, 31 of them by more than their own noise
check-firstblood: OK — the first disabling blow is worth 40 points of wipe rate in the four-player cut, 15.4 in the six-a-side line
check-attackers: OK — three of the four-player cut disable on 11.1% of their attacks or better, and one of them on 0.1%
check-bestiary: OK — 47 creatures, the document matches the game
```
<!-- guards:end -->
+50
View File
@@ -5916,3 +5916,53 @@ now agyeman ... re-record with --update, do not revert the cast."*
**Which is the third time in this sequence that a thing was true only because nobody had
tested it.** The generator comment, the costed restraint, and now the coupling — each
stated confidently, each wrong, each found by somebody trying it rather than reading it.
## R-268 — the sentence that names a player's character, guarded
CLEAN GROUND prints "the four-player cut is three effective attackers, not four. Ashcroft
is not one." It is a claim about a fight, sitting in a document, with nothing checking it.
Re-kit him, or change what the column dodges at, and the warning goes false while the page
keeps printing it — and this one is worse than a wrong statistic, because a GM reads it
aloud to decide who a real player spends four hours being.
**`tools/effective-attackers.mjs` measures it per attack, and the distinction is the whole
entry.** Counted per fight, Braithwaite takes 36% of the cut's disables and reads as its
best attacker. He is its most armoured: downed in 23.8% of fights against 33 to 37 for the
others, so he gets a third more swings, and per attack he is the **weakest** of the three.
| | attacks/fight | lands | damage/hit | attacks that disable | downed in |
|---|---|---|---|---|---|
| Bhattacharya | 6.8 | 17.8% | 5.6 | **13.5%** | 33.1% |
| Renshaw | 7.5 | 17.3% | 5.6 | **13.1%** | 37.1% |
| Braithwaite | 9.9 | 16.2% | 4.6 | **11.1%** | 23.8% |
| Ashcroft | 9.2 | 1.0% | 2.3 | **0.1%** | 34.7% |
A rate that rewards survival is the confound R-259 was caught by. Both are recorded; only
the per-attack column is reasoned from. **Ashcroft is not idle — he makes 9.2 attacks a
fight and lands one in a hundred**, which is worse for a player than being plainly
sidelined, and is the sharper form of the warning the scenario already carries.
**What it asserts.** Exact drift on every figure, then the sentence itself: three of the
four clear 5% of attacks disabling and one does not, and the one who does not is Ashcroft.
The bar sits in a gap of two orders of magnitude, so it tests the claim and not the
sampling. Proved by giving Ashcroft an armed role in a throwaway worktree: the drift check
names him, and **re-recording does not silence it** — the claim check then refuses with
"4 of the cut's 4 clear 5% ... rewrite the warning rather than re-recording."
**One reader for the cast, not two.** `tools/declared-cast.mjs` now holds the marker
reading both scenario guards need. Two copies would be the duplication check-rules exists
to forbid, and would let the two guards disagree about who is in the fight.
**It was called `scenario-cast.mjs` for about four minutes.** `check-scenarios` sweeps
`tools/scenario-*` into the scenario corpus, so the file was read as a scenario, and the
`<!-- cast: ... -->` example in its own header comment was parsed as a real cast
declaration — check-rollable failed with `cast declares "..."`, which is not on the duty
roster. A file named after what it does, colliding with a convention nobody had written
down. Renamed.
**And `update-readme` was hiding the failure.** It collects each guard's line by running
it, and dropped any guard that exited non-zero — so check-rollable vanished from the
README's block, the count word fell to "thirteen" while fourteen guards ran, and the
document got *quieter* as the build got worse. A missing line is now fatal. That is the
third variant of this defect found inside the tooling written to prevent it, which is
starting to look less like bad luck than like where the defect lives.
+1 -1
View File
@@ -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-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.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-creatures.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-bestiary.mjs",
"test": "bun run check",
"readme": "bun tools/update-readme.mjs"
},
+56
View File
@@ -0,0 +1,56 @@
{
"note": "Generated by tools/effective-attackers.mjs --update. Do not edit by hand.",
"runs": 2000,
"seeds": [
11,
4242,
90210
],
"creature": "quiet_neighbours_npc",
"count": 3,
"effectiveBar": 5,
"cast": [
"ashcroft",
"bhattacharya",
"renshaw",
"braithwaite"
],
"agents": {
"ashcroft": {
"name": "Dominic Ashcroft",
"attacksPerFight": 9.2,
"landed": 1,
"damagePerHit": 2.3,
"disablesPerAttack": 0.1,
"downedInPctOfFights": 34.7,
"landedNoise": 0.2
},
"bhattacharya": {
"name": "Arun Bhattacharya",
"attacksPerFight": 6.8,
"landed": 17.8,
"damagePerHit": 5.6,
"disablesPerAttack": 13.5,
"downedInPctOfFights": 33.1,
"landedNoise": 0.7
},
"renshaw": {
"name": "Kate Renshaw",
"attacksPerFight": 7.5,
"landed": 17.3,
"damagePerHit": 5.6,
"disablesPerAttack": 13.1,
"downedInPctOfFights": 37.1,
"landedNoise": 0.2
},
"braithwaite": {
"name": "Neil Braithwaite",
"attacksPerFight": 9.9,
"landed": 16.2,
"damagePerHit": 4.6,
"disablesPerAttack": 11.1,
"downedInPctOfFights": 23.8,
"landedNoise": 0.5
}
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* CLEAN GROUND names an agent who cannot fight, and that sentence must keep being true.
*
* The scaling note prints "the four-player cut is three effective attackers, not four.
* Ashcroft is not one." Give him a sidearm, or change what the column dodges at, and the
* warning is false while the page goes on printing it — a claim nothing checks, which is
* the defect this suite exists for and the one R-267 was caught committing inside a guard.
*
* Thin on purpose: the measurement lives in effective-attackers.mjs, which is also the
* tool a reader runs, so the guard and the reading cannot drift apart.
*/
import { spawnSync } from "node:child_process";
const r = spawnSync(process.execPath, [new URL("effective-attackers.mjs", import.meta.url).pathname, "--check"],
{ encoding: "utf8" });
if (r.status !== 0) {
process.stdout.write(r.stdout ?? "");
process.stderr.write(r.stderr ?? "");
console.error("check-attackers: FAILED — the four-player cut no longer fights the way CLEAN GROUND "
+ "says it does. Read `node tools/effective-attackers.mjs` before re-recording.");
process.exit(1);
}
// update-readme reads the line beginning with this file's own name.
const m = r.stdout.match(/three of the cut disable on ([\d.]+)% of their attacks or better, and Ashcroft on ([\d.]+)%/) ?? [];
console.log(`check-attackers: OK — three of the four-player cut disable on ${m[1] ?? "?"}% of their attacks `
+ `or better, and one of them on ${m[2] ?? "?"}%`);
+58
View File
@@ -0,0 +1,58 @@
/**
* CLEAN GROUND's declared cast, read from the document rather than copied out of it.
*
* Two guards now measure that scenario's own encounter — check-firstblood and
* check-attackers — and both must be measuring the people the document actually casts.
* A cast written out by hand in either of them would go on measuring the old six after a
* re-cast and report success, which is the defect this project keeps finding in itself.
* R-267 committed exactly that inside the guard built to prevent a weaker version of it.
*
* One reader rather than two, because two copies of this would be the duplication
* check-rules exists to forbid, and would let the two guards disagree about who is in the
* fight. check-rollable established the `<!-- cast: ... -->` marker; this reads the same
* one and asks nothing further of the document.
*
* NOT named scenario-something: check-scenarios sweeps `tools/scenario-*` into the
* scenario corpus, so a file with that prefix has its prose read as a scenario's and the
* marker quoted below read as a real cast declaration. It was, and check-rollable said so.
*/
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
export const SCENARIO = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND.md");
/* The scaling note drops Pollard at five and Okonkwo as well at four, so the cut is the
declared cast less those two. Named rather than sliced: "the first four" would survive
a reordering of the cast list and quietly measure different people. */
const DROPPED_AT_FOUR = ["pollard", "okonkwo"];
/** The six the document declares, without their `pc_` prefixes. Fatal if it declares none. */
export function declaredCast(who = "scenario-cast") {
if (!existsSync(SCENARIO)) {
console.error(`${who}: ${path.relative(ROOT, SCENARIO)} is missing, and the cast is read `
+ `from it. Nothing here can be measured against a scenario that is not there.`);
process.exit(1);
}
const m = readFileSync(SCENARIO, "utf8").match(/<!--\s*cast:\s*([^>]*?)\s*-->/i);
if (!m) {
console.error(`${who}: ${path.relative(ROOT, SCENARIO)} declares no cast. Add the `
+ `<!-- cast: ... --> marker check-rollable already reads, or this guard is measuring `
+ `a fight the scenario no longer casts.`);
process.exit(1);
}
return m[1].split(",").map(x => x.trim().replace(/^pc_/, "")).filter(Boolean);
}
/** The declared cast and its four-player cut, checked for the shape the guards recorded. */
export function castAndCut(who = "scenario-cast") {
const line = declaredCast(who);
const cut = line.filter(k => !DROPPED_AT_FOUR.includes(k));
if (cut.length !== 4 || line.length !== 6) {
console.error(`${who}: CLEAN GROUND now casts ${line.length} and its four-player cut works `
+ `out at ${cut.length}. The recorded fights are six against six and four against three; `
+ `re-read the scaling note and re-record with --update rather than reverting.`);
process.exit(1);
}
return { line, cut };
}
+199
View File
@@ -0,0 +1,199 @@
/**
* effective-attackers — what each of the four-player cut actually contributes.
*
* CLEAN GROUND prints "the four-player cut is three effective attackers, not four.
* Ashcroft is not one." That is a sentence about a fight, sitting in a document, with
* nothing checking it — the arrangement this project has now been caught by four times.
* Re-kit Ashcroft, or change what the column dodges at, and the warning becomes false
* silently while the page keeps printing it.
*
* node tools/effective-attackers.mjs read the recorded figures
* node tools/effective-attackers.mjs --check compare against the baseline
* node tools/effective-attackers.mjs --update re-record it
*
* MEASURED PER ATTACK, NOT PER FIGHT, and the distinction is the finding. Counted per
* fight, Braithwaite takes 36% of the cut's disables and looks like its best attacker.
* He is its most ARMOURED: he is downed in a quarter of fights where the others are
* downed in a third, so he gets a third more swings, and per attack he is the weakest of
* the three. A rate that rewards survival is the confound R-259 was caught by, so both
* are recorded here and only the per-attack figures are reasoned from.
*/
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { runFight, seedFor, makeRng } from "./simulate.mjs";
import { castAndCut } from "./declared-cast.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const BASELINE = path.join(ROOT, "tools", "attackers-baseline.json");
const argv = process.argv.slice(2);
const UPDATE = argv.includes("--update");
const CHECK = argv.includes("--check");
const RUNS = 2000;
const SEEDS = [11, 4242, 90210];
const CREATURE = "quiet_neighbours_npc";
const COUNT = 3;
/* The bar the document's sentence implies. Set where the measurement leaves a gap of two
orders of magnitude — the three land a disabling blow on 11 to 14 per cent of their
attacks and Ashcroft on 0.1 — so it tests the claim rather than the sampling. */
const EFFECTIVE = 5;
const { cut: CUT } = castAndCut("effective-attackers");
const party = CUT.map(k => {
const found = ROSTER.find(r => r.key === k || r.key === "pc_" + k);
if (!found) { console.error(`effective-attackers: "${k}" is not on the duty roster`); process.exit(1); }
return found;
});
const spec = NPCS.find(n => n.key === CREATURE);
if (!spec) { console.error(`effective-attackers: no creature keyed "${CREATURE}"`); process.exit(1); }
const foes = Array(COUNT).fill(spec);
const mean = a => a.reduce((x, y) => x + y, 0) / a.length;
const r1 = n => Number(n.toFixed(1));
/** One sweep: every attack each agent makes, and every time they are put down. */
function sweep(seed, runs) {
const t = {};
for (let i = 0; i < runs; i++) {
const downedThisFight = new Set();
runFight(makeRng(seed + i * 2654435761), party, foes, { say: e => {
if (e.kind === "hit" && e.defender.side === "party" && e.defender.hp <= 0
&& !downedThisFight.has(e.defender.name)) {
downedThisFight.add(e.defender.name);
(t[e.defender.name] ??= blank()).downed++;
}
if (!["hit", "miss", "dodged", "stopped"].includes(e.kind)) return;
if (e.attacker?.side !== "party") return;
const a = t[e.attacker.name] ??= blank();
a.attacks++;
if (e.kind === "hit") {
a.hits++; a.damage += e.taken;
if (e.disabled || e.destroyed || e.defender.hp <= 0) a.disables++;
}
}});
}
return t;
}
const blank = () => ({ attacks: 0, hits: 0, damage: 0, disables: 0, downed: 0 });
function measure(runs = RUNS) {
const sweeps = SEEDS.map(s => sweep(seedFor(s, "attackers"), runs));
const agents = {};
for (const p of party) {
const rows = sweeps.map(t => t[p.name] ?? blank());
const landed = rows.map(a => (100 * a.hits) / Math.max(a.attacks, 1));
agents[p.key.replace(/^pc_/, "")] = {
name: p.name,
attacksPerFight: r1(mean(rows.map(a => a.attacks / runs))),
landed: r1(mean(landed)),
damagePerHit: r1(mean(rows.map(a => a.damage / Math.max(a.hits, 1)))),
disablesPerAttack: r1(mean(rows.map(a => (100 * a.disables) / Math.max(a.attacks, 1)))),
downedInPctOfFights: r1(mean(rows.map(a => (100 * a.downed) / runs))),
landedNoise: r1(Math.max(...landed) - Math.min(...landed))
};
}
return agents;
}
if (UPDATE) {
await writeFile(BASELINE, JSON.stringify({
note: "Generated by tools/effective-attackers.mjs --update. Do not edit by hand.",
runs: RUNS, seeds: SEEDS, creature: CREATURE, count: COUNT,
effectiveBar: EFFECTIVE, cast: CUT, agents: measure()
}, null, 2) + "\n", "utf8");
console.log(`effective-attackers: baseline recorded — ${CUT.length} agents, ${RUNS} runs x ${SEEDS.length} seeds`);
process.exit(0);
}
if (!existsSync(BASELINE)) {
console.error("effective-attackers: no baseline. Run with --update to record one.");
process.exit(1);
}
const base = JSON.parse(await readFile(BASELINE, "utf8"));
if (!CHECK) {
console.log(`\nthe four-player cut, per attack — ${base.runs} runs x ${base.seeds.length} seeds\n`);
for (const [key, a] of Object.entries(base.agents).sort((x, y) => y[1].disablesPerAttack - x[1].disablesPerAttack)) {
console.log(` ${a.name.padEnd(18)} ${a.attacksPerFight} attacks/fight · lands ${a.landed}% `
+ `· ${a.damagePerHit} damage per hit · ${a.disablesPerAttack}% of attacks disable `
+ `· downed in ${a.downedInPctOfFights}% of fights`);
}
console.log(`\n Counted per fight instead, the order changes: the most armoured agent gets the`);
console.log(` most swings and takes the most disables while being the weakest per attack.\n`);
process.exit(0);
}
/* ---- --check ------------------------------------------------------------------ */
if (base.runs !== RUNS || base.seeds?.join() !== SEEDS.join()
|| base.creature !== CREATURE || base.count !== COUNT) {
console.error(`effective-attackers: FAILED — the baseline was recorded under different `
+ `conditions (${base.runs} runs, seeds ${base.seeds?.join(",")}, ${base.count} x ${base.creature}). `
+ `Re-record it with --update.`);
process.exit(1);
}
if (base.cast.join() !== CUT.join()) {
const gone = base.cast.filter(k => !CUT.includes(k));
const added = CUT.filter(k => !base.cast.includes(k));
console.error(`effective-attackers: FAILED — the scenario's four-player cut is different people `
+ (gone.length ? `— no longer ${gone.join(", ")}` : "— the same four, reordered")
+ (added.length ? `, now ${added.join(", ")}` : "")
+ `. Re-record with --update; the warning in CLEAN GROUND names an agent and may now name `
+ `the wrong one.`);
process.exit(1);
}
/* Exact comparison is only honest if the measurement is exact. Prove it, don't assume. */
{
const a = JSON.stringify(measure(150));
const b = JSON.stringify(measure(150));
if (a !== b) {
console.error("effective-attackers: FAILED — the simulation is not deterministic, so an exact "
+ "baseline cannot mean anything.");
process.exit(1);
}
}
const now = measure();
const problems = [];
for (const [key, was] of Object.entries(base.agents)) {
const m = now[key];
if (!m) { problems.push(` ${key}: no longer in the cut`); continue; }
for (const f of ["attacksPerFight", "landed", "damagePerHit", "disablesPerAttack", "downedInPctOfFights"]) {
if (m[f] !== was[f]) problems.push(` ${was.name}: ${f} ${was[f]} -> ${m[f]}`);
}
}
if (problems.length) {
console.error(`effective-attackers: FAILED — the cut no longer fights the way it was recorded`);
problems.forEach(p => console.error(p));
process.exit(1);
}
/* The claim CLEAN GROUND prints, restated as a test: three of the four are attackers and
one is not, and the one who is not is Ashcroft. The drift comparison above already
catches every movement, so this asserts only the sentence — a guard that pinned the
exact rates would fail on news rather than on error (R-261). */
const effective = Object.entries(now).filter(([, a]) => a.disablesPerAttack >= EFFECTIVE);
const ineffective = Object.entries(now).filter(([, a]) => a.disablesPerAttack < EFFECTIVE);
if (effective.length !== 3 || ineffective.length !== 1) {
console.error(`effective-attackers: FAILED — ${effective.length} of the cut's ${CUT.length} clear `
+ `${EFFECTIVE}% of attacks disabling. CLEAN GROUND's scaling note says three effective `
+ `attackers and one who is not; that sentence is now wrong. Rewrite the warning rather `
+ `than re-recording.`);
process.exit(1);
}
if (ineffective[0][0] !== "ashcroft") {
console.error(`effective-attackers: FAILED — the agent who is not an attacker is now `
+ `${ineffective[0][1].name}, and CLEAN GROUND's warning names Ashcroft. Rewrite the warning, `
+ `which names the wrong person.`);
process.exit(1);
}
const a = now.ashcroft;
console.log(`effective-attackers: OK — three of the cut disable on ${Math.min(...effective.map(([, x]) => x.disablesPerAttack))}% `
+ `of their attacks or better, and Ashcroft on ${a.disablesPerAttack}% across ${a.attacksPerFight} attacks a fight`);
+4 -36
View File
@@ -29,11 +29,12 @@
* cut rather than of the creature.
*/
import { readFile, writeFile } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs";
import { existsSync } from "node:fs";
import path from "node:path";
import { NPCS } from "./content.mjs";
import { ROSTER } from "./roster.mjs";
import { runFight, seedFor, makeRng } from "./simulate.mjs";
import { castAndCut } from "./declared-cast.mjs";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const BASELINE = path.join(ROOT, "tools", "first-blood-baseline.json");
@@ -48,42 +49,9 @@ const SEEDS = [11, 4242, 90210];
/* CLEAN GROUND's own cast, not the frozen party of the other two guards: this measures a
scenario's encounter, and the whole finding is about what four of these people can do.
Read from the document by tools/scenario-cast.mjs, which check-attackers reads too. */
const { line: LINE, cut: CUT } = castAndCut("first-blood");
Read from the document rather than copied out of it. A cast written here by hand would
go on measuring the old six after a re-cast and report success, which is the defect this
project keeps finding in itself and the one thing a guard must not do. check-rollable
established the marker; this reads the same one. */
const SCENARIO = path.join(ROOT, "docs", "scenarios", "CLEAN_GROUND.md");
/* The scaling note drops Pollard at five and Okonkwo as well at four, so the cut is the
declared cast less those two. Named rather than sliced: "the first four" would survive
a reordering of the cast list and quietly measure different people. */
const DROPPED_AT_FOUR = ["pollard", "okonkwo"];
function declaredCast() {
if (!existsSync(SCENARIO)) {
console.error(`first-blood: ${path.relative(ROOT, SCENARIO)} is missing, and the cast is read `
+ `from it. Nothing here can be measured against a scenario that is not there.`);
process.exit(1);
}
const raw = readFileSync(SCENARIO, "utf8");
const m = raw.match(/<!--\s*cast:\s*([^>]*?)\s*-->/i);
if (!m) {
console.error(`first-blood: ${path.relative(ROOT, SCENARIO)} declares no cast. Add the `
+ `<!-- cast: ... --> marker check-rollable already reads, or this guard is measuring `
+ `a fight the scenario no longer casts.`);
process.exit(1);
}
return m[1].split(",").map(x => x.trim().replace(/^pc_/, "")).filter(Boolean);
}
const LINE = declaredCast();
const CUT = LINE.filter(k => !DROPPED_AT_FOUR.includes(k));
if (CUT.length !== 4 || LINE.length !== 6) {
console.error(`first-blood: CLEAN GROUND now casts ${LINE.length} and its four-player cut `
+ `works out at ${CUT.length}. The recorded fights are six against six and four against `
+ `three; re-read the scaling note and re-record with --update rather than reverting.`);
process.exit(1);
}
const CONFIGS = [
{ id: "cut", label: "four of the roster vs three of the column", party: CUT, creature: "quiet_neighbours_npc", count: 3 },
{ id: "column", label: "six of the roster vs six of the column", party: LINE, creature: "quiet_neighbours_npc", count: 6 }
+14 -3
View File
@@ -37,7 +37,7 @@ const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs",
"check-templates.mjs", "check-behaviour.mjs", "check-scenarios.mjs",
"check-rollable.mjs", "check-creatures.mjs", "check-anatomy.mjs",
"check-lethality.mjs", "check-focus.mjs", "check-firstblood.mjs",
"check-bestiary.mjs"];
"check-attackers.mjs", "check-bestiary.mjs"];
/* The list above went stale the moment a guard was added without touching this file —
which is what the comment above it already warned about, and which happened again
@@ -57,7 +57,18 @@ const GUARDS = ["check-rules.mjs", "check-kits.mjs", "check-lang.mjs",
}
}
const guardLines = GUARDS.map(runGuard).filter(Boolean);
/* A guard that fails, or that prints under a different name, returns null here. Dropping
it silently writes a README advertising fewer guards than the build runs and quietly
lowers the count word — which is how "thirteen guards" was published while fourteen ran
and check-rollable was red. A missing line is now fatal. */
const guardLines = GUARDS.map(name => [name, runGuard(name)]);
const silent = guardLines.filter(([, line]) => !line).map(([name]) => name);
if (silent.length) {
console.error(`update-readme: FAILED — no line from ${silent.join(", ")}. A guard that is `
+ `failing, or that prints under a name other than its own, must not be quietly left out `
+ `of the README. Run \`npm run check\` and fix it first.`);
process.exit(1);
}
const box = `| | |
|---|---|
@@ -90,7 +101,7 @@ if (!text.includes("<!-- box:start -->")) {
const withBox = replaceRegion(text, "box", box);
if (!withBox) { console.error("update-readme: box markers missing"); process.exit(1); }
const withGuards = replaceRegion(withBox, "guards",
"```\n" + guardLines.join("\n") + "\n```");
"```\n" + guardLines.map(([, l]) => l).join("\n") + "\n```");
if (!withGuards) { console.error("update-readme: guard markers missing"); process.exit(1); }
/* The guard BLOCK was generated and the sentences introducing it were not, so the README