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:
co-authored by
Claude Opus 5
parent
a0d15ad7a9
commit
0b86c5ffc3
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] ?? "?"}%`);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user