diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 07b0788..8e36325 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -5809,3 +5809,48 @@ fights that get there. 68% of fights reach round five with none of them down. **The lesson is the one this log keeps recording, one layer out.** A number that cannot be reproduced is a claim, whoever holds it. It was caught because another session tried the citation rather than trusting it, which is exactly what a citation is for. + +## R-266 — the coin is the first disabling blow, and it lands in round two + +R-265 left a finding standing: the four-player cut of CLEAN GROUND is bimodal — 23.1% of +fights end with nobody down, 45.0% with everybody down, and only 11% near the published +mean. It said the fight is *decided by round five*. That was true and it was the wrong +frame: round five is where the outcome is already legible, not where it is decided. + +**Reading one fight from each end says why.** Seed 5 is the clean sweep. Neil puts 5 into +a leg on the first exchange; 5 of 10 is exactly the major-wound threshold, so one of the +three is disabled before anybody else has landed anything. A is down in round 2, C in +round 3 on a critical, B in round 4 before it acts. **The party is hit once in the whole +fight** — 4 points through Neil's armour. Seed 2 is the wipe, and its first two rounds +produce a single 3-point scratch; the column lands three blows in round 3, Kate goes 12 to +0 and Arun is disabled by a critical, and the cut is two effective attackers against three. + +**Both sides disable on one good hit.** The major-wound threshold is half of max HP and it +is the same rule in both directions — the neighbours have 10, the agents 11 to 13 — so an +average blow takes a fighter out of the fight. Every disable removes an attacker, which +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:** + +| 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%** | + +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 +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. + +**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. diff --git a/tools/first-blood.mjs b/tools/first-blood.mjs new file mode 100644 index 0000000..0a714c7 --- /dev/null +++ b/tools/first-blood.mjs @@ -0,0 +1,73 @@ +/** + * 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. + * + * 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 + * + * 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. + */ +import { ROSTER } from "./roster.mjs"; +import { NPCS } from "./content.mjs"; +import { runFight, makeRng } from "./simulate.mjs"; + +const argv = process.argv.slice(2); +const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; }; + +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 spec = NPCS.find(n => n.key === KEY); +if (!spec) { console.error(`first-blood: no creature keyed "${KEY}"`); process.exit(1); } + +const partyKeys = (opt("--party", "ashcroft,bhattacharya,renshaw,braithwaite")) + .split(",").map(x => x.trim()).filter(Boolean); +const party = partyKeys.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); + +/* 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++; +} + +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`); +} +if (undecided) console.log(` ${undecided} fights with nobody disabled at all`);