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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 09:42:31 +01:00
co-authored by Claude Opus 5
parent 8e5a30a263
commit ce1103ec0d
7 changed files with 357 additions and 74 deletions
+7 -6
View File
@@ -48,7 +48,7 @@ packs or regenerate art on that machine:
```bash
npm install # pulls classic-level, used to write the LevelDB packs
npm run build # rebuilds packs/ from tools/content.mjs
npm run check # the 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:
<!-- guards:start -->
```
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
```
<!-- guards:end -->
+55 -11
View File
@@ -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.
+1 -1
View File
@@ -11,7 +11,7 @@
"play": "node tools/playthrough.mjs",
"mj": "node tools/mj-queue.mjs",
"simulate": "node tools/simulate.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-creatures.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-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"
},
+28
View File
@@ -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`);
+51
View File
@@ -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
}
}
}
+210 -53
View File
@@ -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`);
+5 -3
View File
@@ -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");