From ce1103ec0dfa0e6d0835bb082c66bb028c34aab2 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:42:31 +0100 Subject: [PATCH] R-267: a guarded home for the first disabling blow Another session verified R-266's measurement, agreed the frame was better than its own, and declined to publish it because the figure had no guarded lineage a doubter could re-derive. It was right, and the fix costs 0.7s of build time -- which is the number I should have checked before calling it a reading tool and not a guard. first-blood.mjs gains --update/--check and is now both the reader and the measurement of record; check-firstblood.mjs is a thin wrapper over it, the same shape as check-bestiary. Compares the recorded figures exactly, then asserts only what a page would claim: first blood lands within 5 points of even in the cut, and its swing exceeds the six-a-side line's by more than both noises. Recording it moved the swing from the scratch run's 43 to 40 against a seed-to-seed spread of 2.1 -- high by more than its own noise, which is the argument in miniature. update-readme's COUNT_WORD could not reach thirteen. Co-Authored-By: Claude Opus 5 --- README.md | 13 +- docs/REVIEW_LOG.md | 66 ++++++-- package.json | 2 +- tools/check-firstblood.mjs | 28 ++++ tools/first-blood-baseline.json | 51 +++++++ tools/first-blood.mjs | 263 +++++++++++++++++++++++++------- tools/update-readme.mjs | 8 +- 7 files changed, 357 insertions(+), 74 deletions(-) create mode 100644 tools/check-firstblood.mjs create mode 100644 tools/first-blood-baseline.json diff --git a/README.md b/README.md index f2e87f7..0f675b6 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 twelve guards; the build refuses to run if they fail +npm run check # the thirteen 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 twelve guards first and refuses on failure + build-packs.mjs builds packs/ — runs the thirteen 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,21 +178,22 @@ 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 twelve guards before it writes anything: +The build runs all thirteen guards before it writes anything: ``` -check-rules: OK — 110 rules, 38 files scanned, 3 aliases + 46 constant sets checked, 395 formulas verified +check-rules: OK — 110 rules, 40 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 check-behaviour: OK — 76 behavioural tests -check-scenarios: OK — 13 scenario files, 80 mechanic tags, every skill named resolves against 62 catalogue entries -check-rollable: OK — 79 rolls across 13 scenario files, every one reachable at 25% or better by somebody present +check-scenarios: OK — 13 scenario files, 83 mechanic tags, every skill named resolves against 62 catalogue entries +check-rollable: OK — 82 rolls across 13 scenario files, every one reachable at 25% or better by somebody present check-creatures: OK — 102 actor specs across 8 sources, every characteristic, skill, kit key, talent, species and style resolves, no duplicate keys check-anatomy: OK — 5 body plans, 36 locations, d20 complete in both modes, every location drawn, every kind handled and destructible, 20 plan changes carried 432 wounds with their severity intact, 1 flyer(s) with a survivable landing 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-bestiary: OK — 47 creatures, the document matches the game ``` diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 8e36325..d2f6057 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -5831,26 +5831,70 @@ average blow takes a fighter out of the fight. Every disable removes an attacker thins the return fire, which makes the next disable likelier. Nothing pulls a fight back toward the middle once it has tipped, which is the whole of the bimodality. -**Measured, `tools/first-blood.mjs`, 2000 runs at seed 11:** +**Measured, `tools/first-blood.mjs`, 2000 runs across three seeds:** | first fighter out | share of fights | mean round | wipes among those | |---|---|---|---| -| the party strikes first | 49.7% | 1.7 | **23.1%** | -| the column strikes first | 50.3% | 1.7 | **66.5%** | +| the party strikes first | 50.3% | 1.8 | **25.5%** | +| the column strikes first | 49.7% | 1.8 | **65.5%** | -A true coin, settled in round two, worth **43 points of wipe rate**. Six against six of the -column is the control: first blood still matters there and matters far less — 8.6% against -22.4%, a 14-point swing, at mean round 1.3. One fighter is a sixth of that line and a +A true coin, settled in round two, worth **40 points of wipe rate**. Six against six of the +column is the control: first blood still matters there and matters far less — 7.8% against +23.2%, a 15-point swing, at mean round 1.3. One fighter is a sixth of that line and a quarter of the cut, so the same blow compounds less. This is a property of the cut, which is what the histogram said by a different route. +*Figures above are the recorded artifact, not the scratch run this entry was first written +from. That run was one seed and gave 49.7/23.1/66.5 and a swing of 43. Averaging three +seeds moves it to 40, and the seed-to-seed spread of the swing is 2.1 — so the single-seed +figure was high by more than its own noise, which is the argument for recording it rather +than quoting it. See R-267.* + **Dominic should be read before anyone is handed him.** In both narrations he attacks at `vs 1` — four attempts in the seed 5 fight, all of them hopeless. The four-player cut is three effective attackers, not four, and a table that assigns Ashcroft to a player without saying so has quietly made the coin worse. -**`tools/first-blood.mjs` is a reading tool, not a guard.** It records nothing and no -figure from it belongs on a generated page — the bestiary and the scenarios read guarded -artifacts only. It is in the repo rather than in a scratch directory for one reason: the -number above was quoted to another session, and R-265's lesson is that a number the -person quoted it cannot re-run is a claim, not a result. +**`tools/first-blood.mjs` was a reading tool and not a guard when this was written.** +That was the right place to stop and the wrong place to stay — see R-267, which is the +consequence of being told so. + +## R-267 — the thirteenth guard, built because a peer refused to print the twelfth's number + +R-266 measured why the four-player cut is bimodal and said plainly that the figure had no +guarded home, so it should not reach a page. The session holding the scenario agreed and +went further: it verified both configurations, said the frame was better than its own, and +**still would not publish it**, because the numbers already in CLEAN GROUND come out of the +guarded lineage and can be re-derived by a doubter while mine could not. It offered to +reframe the paragraph the moment a guarded home existed. + +That is the correct call and it is the whole reason this entry exists. The refusal was +worth more than the finding. + +**What it cost to fix: 0.7 seconds of build time.** That was the number I should have +checked before writing "a reading tool, not a guard" as though it were a considered +position. `check-focus` runs 1000 fights x 3 seeds x 47 creatures and takes 8.6s; this +runs 2000 x 3 x 2 configurations and takes 0.7s. There was never a cost argument. + +**Shape.** `tools/first-blood.mjs` gained `--update` and `--check` and is now both the +reading tool and the measurement of record; `tools/check-firstblood.mjs` is a thin wrapper +that spawns it, the same arrangement as `check-bestiary`/`bestiary.mjs`. The reader and +the guard cannot disagree, because they are the same code path — which is the defect this +whole sequence started from, one layer down. + +**What it asserts, and deliberately no more.** The recorded figures are compared exactly, +so any movement at all shows as drift. On top of that, two claims, both of which a page +would actually make: first blood in the cut lands within 5 points of even, and the swing +it is worth in the cut exceeds the swing in the six-a-side line by more than both +samplings' noise. A guard that asserted the swing's exact size would fail on news rather +than on error — R-261, which this suite has already learned once. + +**Recording it changed the number, which is the argument in miniature.** The scratch run +said the first blow was worth 43 points. Three seeds say 40, against a seed-to-seed spread +of 2.1 — so the figure I sent another session was high by more than its own noise. It was +not wrong enough to mislead anybody and it was wrong enough to print. + +**`tools/update-readme.mjs` could not count to thirteen.** Its `COUNT_WORD` table stopped +at twelve, which is exactly the hardcoded-list failure its own comment warns about two +lines above, committed by the file that exists to prevent it. Extended to sixteen, which +defers the same defect rather than curing it. diff --git a/package.json b/package.json index 40fb1b5..f7287cb 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-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-bestiary.mjs", "test": "bun run check", "readme": "bun tools/update-readme.mjs" }, diff --git a/tools/check-firstblood.mjs b/tools/check-firstblood.mjs new file mode 100644 index 0000000..84e4972 --- /dev/null +++ b/tools/check-firstblood.mjs @@ -0,0 +1,28 @@ +/** + * The four-player cut turns on its first disabling blow, and it must keep doing so. + * + * R-266 found the mechanism behind CLEAN GROUND's bimodal outcome: both sides disable on + * one good hit, so whoever lands the first one thins the return fire and makes the next + * likelier. It was measured in a scratch harness, and another session declined to put it + * in the scenario for that reason — correctly, and that refusal is why this guard exists. + * + * Thin on purpose. The measurement lives in first-blood.mjs, which is also the tool a + * reader runs; re-deriving it here would be the duplication check-rules exists to forbid, + * and would let the page and the guard drift apart again. + */ +import { spawnSync } from "node:child_process"; + +const r = spawnSync(process.execPath, [new URL("first-blood.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-firstblood: FAILED — the fight no longer turns the way it was recorded. " + + "Read `node tools/first-blood.mjs` before re-recording: a changed swing is a changed scenario."); + process.exit(1); +} +// update-readme reads the line beginning with this file's own name. +const m = r.stdout.match(/worth ([\d.]+) points of wipe rate in the cut, ([\d.]+) in the line/) ?? []; +console.log(`check-firstblood: OK — the first disabling blow is worth ${m[1] ?? "?"} points of wipe rate ` + + `in the four-player cut, ${m[2] ?? "?"} in the six-a-side line`); diff --git a/tools/first-blood-baseline.json b/tools/first-blood-baseline.json new file mode 100644 index 0000000..0107045 --- /dev/null +++ b/tools/first-blood-baseline.json @@ -0,0 +1,51 @@ +{ + "note": "Generated by tools/first-blood.mjs --update. Do not edit by hand.", + "runs": 2000, + "seeds": [ + 11, + 4242, + 90210 + ], + "configs": { + "cut": { + "label": "four of the roster vs three of the column", + "party": [ + "ashcroft", + "bhattacharya", + "renshaw", + "braithwaite" + ], + "creature": "quiet_neighbours_npc", + "count": 3, + "firstShare": 50.3, + "meanRound": 1.8, + "wipeIfParty": 25.5, + "wipeIfColumn": 65.5, + "swing": 40, + "shareNoise": 3.8, + "swingNoise": 2.1, + "undecided": 0 + }, + "column": { + "label": "six of the roster vs six of the column", + "party": [ + "ashcroft", + "bhattacharya", + "renshaw", + "braithwaite", + "pollard", + "okonkwo" + ], + "creature": "quiet_neighbours_npc", + "count": 6, + "firstShare": 54.1, + "meanRound": 1.3, + "wipeIfParty": 7.8, + "wipeIfColumn": 23.2, + "swing": 15.4, + "shareNoise": 2.3, + "swingNoise": 2.1, + "undecided": 0 + } + } +} diff --git a/tools/first-blood.mjs b/tools/first-blood.mjs index 0a714c7..f8f7e13 100644 --- a/tools/first-blood.mjs +++ b/tools/first-blood.mjs @@ -1,73 +1,230 @@ /** - * Who lands the first disabling blow, and what the fight does afterwards. + * first-blood — who lands the first disabling blow, and what the fight does afterwards. * - * `check-focus` and `check-lethality` record how a fight ENDS. Neither records how it - * turns, and the four-player cut of CLEAN GROUND turns early: both sides disable on one - * good hit, so the first one to land it removes an attacker, which thins the return fire, - * which makes the next one likelier. This counts that first blow and reports the outcome - * on each side of it. + * check-lethality and check-focus record how a fight ENDS. Neither records how it TURNS, + * and the four-player cut of CLEAN GROUND turns early: both sides disable on one good + * hit, so whoever lands it first removes an attacker, which thins the return fire, which + * makes the next disable likelier. Nothing pulls the fight back toward the middle once it + * has tipped, and that — not anything about round five — is where the scenario's bimodal + * outcome comes from. * - * node tools/first-blood.mjs the four-player cut - * node tools/first-blood.mjs --creature redcap --count 3 \ - * --party ashcroft,bhattacharya,renshaw,braithwaite --runs 2000 --seed 11 + * R-266 measured that in a scratch harness and another session, correctly, would not put + * it in a scenario: a figure nobody can re-derive is a claim. So the measurement is + * recorded as an artifact and re-run on every build, the way check-focus does it, and + * the reading tool and the guard are the same file so they cannot drift apart. * - * This is a reading tool, not a guard: nothing here is recorded to a baseline and no - * figure from it belongs on a generated page. It exists so a number quoted between - * sessions can be re-run by whoever was quoted it, which is the whole of R-265's lesson. + * node tools/first-blood.mjs read the configured fights + * node tools/first-blood.mjs --check compare against the committed baseline + * node tools/first-blood.mjs --update re-record it (after a deliberate change) + * node tools/first-blood.mjs --creature redcap --count 3 --runs 500 + * an unrecorded fight, for reading only + * + * WHAT IS MEASURED. The first hit of the fight that disables a location, destroys one, + * or drops a fighter — the three conditions the narration marks — and which side landed + * it. Then the wipe rate on each side of that, which is what the first blow is worth. + * + * WHY TWO CONFIGURATIONS. The cut is the claim; six against six is the control. One + * fighter is a quarter of the cut and a sixth of the line, so the same blow compounds + * less, and the difference between the two swings is what makes this a property of the + * cut rather than of the creature. */ -import { ROSTER } from "./roster.mjs"; +import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; import { NPCS } from "./content.mjs"; -import { runFight, makeRng } from "./simulate.mjs"; +import { ROSTER } from "./roster.mjs"; +import { runFight, seedFor, makeRng } from "./simulate.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"); const argv = process.argv.slice(2); -const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; }; +const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; }; +const UPDATE = argv.includes("--update"); +const CHECK = argv.includes("--check"); -const KEY = opt("--creature", "quiet_neighbours_npc"); -const COUNT = Number(opt("--count", 3)); const RUNS = Number(opt("--runs", 2000)); -const SEED = Number(opt("--seed", 11)); +const SEEDS = [11, 4242, 90210]; -const spec = NPCS.find(n => n.key === KEY); -if (!spec) { console.error(`first-blood: no creature keyed "${KEY}"`); process.exit(1); } +/* 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. */ +const CUT = ["ashcroft", "bhattacharya", "renshaw", "braithwaite"]; +const LINE = [...CUT, "pollard", "okonkwo"]; +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 } +]; -const partyKeys = (opt("--party", "ashcroft,bhattacharya,renshaw,braithwaite")) - .split(",").map(x => x.trim()).filter(Boolean); -const party = partyKeys.map(k => { +const roster = keys => keys.map(k => { const found = ROSTER.find(r => r.key === k || r.key === "pc_" + k); if (!found) { console.error(`first-blood: "${k}" is not on the duty roster`); process.exit(1); } return found; }); -const foes = Array.from({ length: COUNT }, () => spec); +const creature = key => { + const spec = NPCS.find(n => n.key === key); + if (!spec) { console.error(`first-blood: no creature keyed "${key}"`); process.exit(1); } + return spec; +}; +const mean = a => a.reduce((x, y) => x + y, 0) / a.length; +const r1 = n => Number(n.toFixed(1)); -/* A fighter leaves the fight when a hit disables or destroys a location, or drops them — - the same three conditions the narration marks, so the count matches what a reader sees. */ -const rng = makeRng(SEED); -const by = { party: { n: 0, wiped: 0, rounds: 0 }, column: { n: 0, wiped: 0, rounds: 0 } }; -let undecided = 0; - -for (let i = 0; i < RUNS; i++) { - let first = null, firstRound = 0, round = 0; - const r = runFight(rng, party, foes, { say: e => { - if (e.kind === "round") round = e.round; - if (first) return; - if (e.kind === "hit" && (e.disabled || e.destroyed || e.defender.hp <= 0)) { - first = e.defender.side === "enemy" ? "party" : "column"; - firstRound = round; - } - }}); - if (!first) { undecided++; continue; } - const b = by[first]; - b.n++; b.rounds += firstRound; - if (r.wiped) b.wiped++; +/** One sweep: who struck first, in what round, and how those fights ended. */ +function sweep(party, foes, seed, runs) { + const by = { party: { n: 0, wiped: 0, rounds: 0 }, column: { n: 0, wiped: 0, rounds: 0 } }; + let undecided = 0; + for (let i = 0; i < runs; i++) { + let first = null, firstRound = 0, round = 0; + const r = runFight(makeRng(seed + i * 2654435761), party, foes, { say: e => { + if (e.kind === "round") round = e.round; + if (first) return; + if (e.kind === "hit" && (e.disabled || e.destroyed || e.defender.hp <= 0)) { + first = e.defender.side === "enemy" ? "party" : "column"; + firstRound = round; + } + }}); + if (!first) { undecided++; continue; } + const b = by[first]; + b.n++; b.rounds += firstRound; + if (r.wiped) b.wiped++; + } + const decided = by.party.n + by.column.n; + return { + firstShare: (100 * by.party.n) / Math.max(decided, 1), + meanRound: (by.party.rounds + by.column.rounds) / Math.max(decided, 1), + wipeIfParty: (100 * by.party.wiped) / Math.max(by.party.n, 1), + wipeIfColumn: (100 * by.column.wiped) / Math.max(by.column.n, 1), + undecided + }; } -console.log(`\nfirst fighter out of the fight — ${RUNS} runs, seed ${SEED}`); -console.log(`${party.length} of the roster vs ${COUNT} × ${spec.name ?? KEY}\n`); -for (const side of ["party", "column"]) { - const b = by[side]; - if (!b.n) continue; - console.log(` ${side.padEnd(7)} strikes first in ${String((100 * b.n / RUNS).toFixed(1)).padStart(5)}% of fights` - + ` (mean round ${(b.rounds / b.n).toFixed(1)})` - + ` → ${String((100 * b.wiped / b.n).toFixed(1)).padStart(5)}% of those end in a wipe`); +/** The recorded measurement: three seeds, averaged, with the seed-to-seed spread kept. */ +function measure(cfg, runs = RUNS) { + const party = roster(cfg.party); + const foes = Array(cfg.count).fill(creature(cfg.creature)); + const each = SEEDS.map(s => sweep(party, foes, seedFor(s, cfg.id), runs)); + const swings = each.map(e => e.wipeIfColumn - e.wipeIfParty); + const shares = each.map(e => e.firstShare); + return { + party: cfg.party, creature: cfg.creature, count: cfg.count, + firstShare: r1(mean(shares)), + meanRound: r1(mean(each.map(e => e.meanRound))), + wipeIfParty: r1(mean(each.map(e => e.wipeIfParty))), + wipeIfColumn: r1(mean(each.map(e => e.wipeIfColumn))), + swing: r1(mean(swings)), + shareNoise: r1(Math.max(...shares) - Math.min(...shares)), + swingNoise: r1(Math.max(...swings) - Math.min(...swings)), + undecided: each.reduce((n, e) => n + e.undecided, 0) + }; } -if (undecided) console.log(` ${undecided} fights with nobody disabled at all`); + +/* An ad-hoc fight, for reading rather than recording. Nothing here reaches the baseline. */ +if (!UPDATE && !CHECK && (argv.includes("--creature") || argv.includes("--party") || argv.includes("--count"))) { + const cfg = { + id: "ad-hoc", label: "an unrecorded fight", + party: (opt("--party", CUT.join(","))).split(",").map(x => x.trim()).filter(Boolean), + creature: opt("--creature", "quiet_neighbours_npc"), + count: Number(opt("--count", 3)) + }; + const m = measure(cfg, RUNS); + console.log(`\nfirst fighter out of the fight — ${RUNS} runs x ${SEEDS.length} seeds, unrecorded`); + console.log(`${cfg.party.length} of the roster vs ${cfg.count} x ${creature(cfg.creature).name ?? cfg.creature}\n`); + console.log(` party strikes first in ${m.firstShare}% of fights (mean round ${m.meanRound})`); + console.log(` wipes: ${m.wipeIfParty}% when the party strikes first, ${m.wipeIfColumn}% when it does not ` + + `— a swing of ${m.swing}`); + process.exit(0); +} + +if (UPDATE) { + const configs = {}; + for (const cfg of CONFIGS) configs[cfg.id] = { label: cfg.label, ...measure(cfg) }; + await writeFile(BASELINE, JSON.stringify({ + note: "Generated by tools/first-blood.mjs --update. Do not edit by hand.", + runs: RUNS, seeds: SEEDS, configs + }, null, 2) + "\n", "utf8"); + console.log(`first-blood: baseline recorded — ${CONFIGS.length} configurations, ` + + `${RUNS} runs x ${SEEDS.length} seeds`); + process.exit(0); +} + +if (!existsSync(BASELINE)) { + console.error("first-blood: no baseline. Run with --update to record one."); + process.exit(1); +} +const base = JSON.parse(await readFile(BASELINE, "utf8")); + +if (!CHECK) { + console.log(`\nfirst fighter out of the fight — ${base.runs} runs x ${base.seeds.length} seeds`); + for (const [id, was] of Object.entries(base.configs)) { + console.log(`\n ${was.label}`); + console.log(` party strikes first in ${was.firstShare}% of fights (mean round ${was.meanRound})`); + console.log(` wipes: ${was.wipeIfParty}% when the party strikes first, ${was.wipeIfColumn}% when it does not ` + + `— a swing of ${was.swing}`); + } + console.log(`\n recorded by tools/first-blood.mjs --update; re-checked on every build.\n`); + process.exit(0); +} + +/* ---- --check ------------------------------------------------------------------ */ + +if (base.runs !== RUNS || base.seeds?.join() !== SEEDS.join()) { + console.error(`first-blood: FAILED — the baseline was recorded under different conditions ` + + `(${base.runs} runs, seeds ${base.seeds?.join(",")}). Re-record it with --update.`); + process.exit(1); +} + +/* Exact comparison is only honest if the measurement is exact. Prove it, don't assume. */ +{ + const probe = CONFIGS[0]; + const a = JSON.stringify(measure(probe, 200)); + const b = JSON.stringify(measure(probe, 200)); + if (a !== b) { + console.error("first-blood: FAILED — the simulation is not deterministic, so an exact " + + `baseline cannot mean anything. ${probe.id} measured twice gave ${a} and ${b}.`); + process.exit(1); + } +} + +const now = {}, problems = []; +for (const cfg of CONFIGS) { + const was = base.configs[cfg.id]; + if (!was) { problems.push(` ${cfg.id}: not in the baseline — re-record with --update`); continue; } + if (was.party.join() !== cfg.party.join() || was.creature !== cfg.creature || was.count !== cfg.count) { + problems.push(` ${cfg.id}: recorded against a different fight (${was.party.length} vs ${was.count} x ${was.creature})`); + continue; + } + const m = now[cfg.id] = measure(cfg); + for (const k of ["firstShare", "meanRound", "wipeIfParty", "wipeIfColumn", "swing"]) { + if (m[k] !== was[k]) problems.push(` ${cfg.id}: ${k} ${was[k]} -> ${m[k]}`); + } +} +for (const id of Object.keys(base.configs)) { + if (!CONFIGS.some(c => c.id === id)) problems.push(` ${id}: gone from the tool — re-record with --update`); +} + +if (problems.length) { + console.error(`first-blood: FAILED — the fight no longer turns the way it was recorded`); + problems.forEach(p => console.error(p)); + process.exit(1); +} + +/* The claims, restated as tests rather than as prose. Only what a page would assert: the + drift comparison above already catches every movement, and a guard that asserts more + than the finding needs is a guard that fails on news rather than on error — R-261. */ +const cut = now.cut, col = now.column; + +if (Math.abs(cut.firstShare - 50) > 5) { + console.error(`first-blood: FAILED — first blood is no longer near even in the cut ` + + `(${cut.firstShare}% to the party). The finding calls it a coin, and a coin that lands ` + + `one way ${cut.firstShare}% of the time is not one. Rewrite the finding, don't re-record.`); + process.exit(1); +} +if (cut.swing <= col.swing + cut.swingNoise + col.swingNoise) { + console.error(`first-blood: FAILED — the first blow is worth ${cut.swing} points in the cut and ` + + `${col.swing} in the six-a-side line, which is no longer a difference this sampling can see ` + + `(noise ${cut.swingNoise} and ${col.swingNoise}). The finding says the compounding is a ` + + `property of the cut; rewrite it rather than re-recording.`); + process.exit(1); +} + +console.log(`first-blood: OK — first blood lands round ${cut.meanRound} and is worth ${cut.swing} points ` + + `of wipe rate in the cut, ${col.swing} in the line`); diff --git a/tools/update-readme.mjs b/tools/update-readme.mjs index c59d9a1..325cb19 100644 --- a/tools/update-readme.mjs +++ b/tools/update-readme.mjs @@ -36,7 +36,8 @@ const runGuard = name => { 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-bestiary.mjs"]; + "check-lethality.mjs", "check-focus.mjs", "check-firstblood.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 @@ -96,10 +97,11 @@ if (!withGuards) { console.error("update-readme: guard markers missing"); proces listed nine guards while three places in the prose still said eight. Count words go stale the same way a hardcoded list does — the lesson two comments up, one layer out. */ const COUNT_WORD = ["zero", "one", "two", "three", "four", "five", "six", "seven", - "eight", "nine", "ten", "eleven", "twelve"]; + "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", + "fifteen", "sixteen"]; const word = COUNT_WORD[guardLines.length]; if (!word) { console.error(`update-readme: ${guardLines.length} guards has no count word`); process.exit(1); } -const counted = withGuards.replace(/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve) guards\b/g, +const counted = withGuards.replace(/\b(zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen) guards\b/g, `${word} guards`); await writeFile(readme, counted, "utf8");