From 0b86c5ffc369861d5b34638aa671d17463ac3b35 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:56:01 +0100 Subject: [PATCH] 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 --- README.md | 9 +- docs/REVIEW_LOG.md | 50 +++++++++ package.json | 2 +- tools/attackers-baseline.json | 56 ++++++++++ tools/check-attackers.mjs | 27 +++++ tools/declared-cast.mjs | 58 ++++++++++ tools/effective-attackers.mjs | 199 ++++++++++++++++++++++++++++++++++ tools/first-blood.mjs | 40 +------ tools/update-readme.mjs | 17 ++- 9 files changed, 414 insertions(+), 44 deletions(-) create mode 100644 tools/attackers-baseline.json create mode 100644 tools/check-attackers.mjs create mode 100644 tools/declared-cast.mjs create mode 100644 tools/effective-attackers.mjs diff --git a/README.md b/README.md index 0f675b6..6be89fe 100644 --- a/README.md +++ b/README.md @@ -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: ``` -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 ``` diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 5b2c7fd..9461523 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -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 +`` 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. diff --git a/package.json b/package.json index f7287cb..965dae1 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-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" }, diff --git a/tools/attackers-baseline.json b/tools/attackers-baseline.json new file mode 100644 index 0000000..5401f83 --- /dev/null +++ b/tools/attackers-baseline.json @@ -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 + } + } +} diff --git a/tools/check-attackers.mjs b/tools/check-attackers.mjs new file mode 100644 index 0000000..003f704 --- /dev/null +++ b/tools/check-attackers.mjs @@ -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] ?? "?"}%`); diff --git a/tools/declared-cast.mjs b/tools/declared-cast.mjs new file mode 100644 index 0000000..7fa199e --- /dev/null +++ b/tools/declared-cast.mjs @@ -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 `` 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(//i); + if (!m) { + console.error(`${who}: ${path.relative(ROOT, SCENARIO)} declares no cast. Add the ` + + ` 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 }; +} diff --git a/tools/effective-attackers.mjs b/tools/effective-attackers.mjs new file mode 100644 index 0000000..8a9c850 --- /dev/null +++ b/tools/effective-attackers.mjs @@ -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`); diff --git a/tools/first-blood.mjs b/tools/first-blood.mjs index 18cfaac..f7e9aaf 100644 --- a/tools/first-blood.mjs +++ b/tools/first-blood.mjs @@ -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(//i); - if (!m) { - console.error(`first-blood: ${path.relative(ROOT, SCENARIO)} declares no cast. Add the ` - + ` 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 } diff --git a/tools/update-readme.mjs b/tools/update-readme.mjs index 325cb19..395cd9a 100644 --- a/tools/update-readme.mjs +++ b/tools/update-readme.mjs @@ -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("")) { 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