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:
co-authored by
Claude Opus 5
parent
8e5a30a263
commit
ce1103ec0d
@@ -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
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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`);
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user