diff --git a/README.md b/README.md index 819c84b..f2e87f7 100644 --- a/README.md +++ b/README.md @@ -187,12 +187,12 @@ check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resol 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 — 12 scenario files, 79 mechanic tags, every skill named resolves against 62 catalogue entries -check-rollable: OK — 78 rolls across 12 scenario files, every one reachable at 25% or better by somebody present +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-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 30 packs, helps in all of them, 28 of them by more than their own noise +check-focus: OK — focus fire measured against 31 packs, helps in all of them, 31 of them by more than their own noise check-bestiary: OK — 47 creatures, the document matches the game ``` diff --git a/docs/BESTIARY.md b/docs/BESTIARY.md index 7e0d860..917cf4f 100644 --- a/docs/BESTIARY.md +++ b/docs/BESTIARY.md @@ -87,12 +87,12 @@ rule for holding an action. Arranging the party so its best weapon fires last wa measured in the simulator and came to nothing distinguishable from noise. **What you can do is kill them one at a time.** Against a pack, every agent putting -their attacks into the same creature beats spreading them — in 30 of the -30 fights in this book whose outcome was ever in doubt, and by more -than the measurement’s own noise in 28 of them. It is worth about -**8 points** of win rate against a pair and -**12 points** against three or more; the best case in the book is -3 × Spriggan, which goes 38.9% to 55.5%. Most of that is not the ladder +their attacks into the same creature beats spreading them — in **every one** of the +31 fights in this book whose outcome was ever in doubt, without exception, and by more +than the measurement’s own noise each time. It is worth about +**7 points** of win rate against a pair and +**11 points** against three or more; the best case in the book is +3 × Kelpie, which goes 52.0% to 67.1%. Most of that is not the ladder above: a creature that is dead stops attacking, and one on half its hit points does not attack any less. Held at a fixed pack size the dodge ladder is worth a point or two. diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 9e4972f..5eb1397 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -5765,3 +5765,47 @@ than the page needs to: the bestiary prints the above-noise COUNT from the artif cannot overstate it however it moves, and what the page actually asserts outright is that concentrating fire helps. The guard now checks exactly that, and the page rewrote itself to "in 28 of them" on regeneration. + +## R-265 — two generators, so a citation named two different fights + +`tools/playthrough.mjs` exists to show a reader WHY a measured number is what it is. I +cited it to another session — read seed 2, see the wipe the 45% row is made of — and the +reproduction failed in front of them. They reported different outcomes at both seeds and +guessed the cause correctly from the outside: the two tools were not drawing from the same +stream. + +They were not. `simulate.mjs` used a private mulberry32 `makeRng`; `playthrough.mjs` had +its own linear congruential generator, written to look like it. Both deterministic, both +reproducible on their own, and **"seed 2" named a different fight in each** — which +quietly breaks the only thing the tool is for. Its own comment claimed "a seed here names +the same fight there". `check-focus` had a third copy of the same LCG, so the two lethality +guards described the same game with different dice. + +`makeRng` is exported, and both files use it. A playthrough seed is now exactly the first +fight of `simulate.mjs --seed `: verified by running `--runs 1 --seed 2` and the +playthrough side by side — 9 rounds, 4 of 4 down, 1 dead, both. + +**The other half was my citation, not the code.** The command I sent omitted `--mode`, so +it plays BOTH targeting arms and prints two fights; they read the last line, I quoted the +first. The summary line now names the arm — `→ [random] PARTY WIPED …` — so a quotation +cannot be ambiguous. + +**What it cost.** check-focus re-recorded under the shared stream: 31 of 47 measurable +again, and the numbers move by a point or two each. What it buys is that every figure in +the focus baseline is now drawn from the same dice as the lethality baseline, and the +bimodality analysis below reproduces the published means **exactly** — 2.31, 3.03, 0.40 and +0.42 of 4 or 6 down, against the four rows CLEAN GROUND publishes. Under the old LCG it +agreed to within a decimal, which looked like corroboration and was really two different +experiments landing near each other. + +**The finding that started it survives, and is now checkable.** The four-player cut against +three of the column is bimodal: 23.1% of fights end with nobody down, 45.0% with everybody +down, and 11% land on the published mean of 2.31. Six players against six of the column is +not bimodal at all — 10 to 18% at every outcome — so this is a property of the cut rather +than of the creature. The mechanism is a race: put one of the three down inside five rounds +and the wipe rate falls from 58.5% to 19.7%; put down two and it is zero in 2000 fights. +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. diff --git a/system.json b/system.json index edc5ace..c04f7fa 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "ringbrp", "title": "The Custodians", "description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.", - "version": "1.8.0", + "version": "1.8.1", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/tools/check-focus.mjs b/tools/check-focus.mjs index 7d67d6e..99e8806 100644 --- a/tools/check-focus.mjs +++ b/tools/check-focus.mjs @@ -31,7 +31,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { NPCS } from "./content.mjs"; import { ROSTER } from "./roster.mjs"; -import { runFight, seedFor } from "./simulate.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", "focus-baseline.json"); @@ -67,7 +67,10 @@ const party = PARTY_KEYS.map(k => { /* One generator per arm, derived from the creature's own key, so inserting a creature cannot perturb its neighbours — R-252, learned the hard way. */ -const rngFor = seed => { let s = seed >>> 0; return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296); }; +/* R-265: the harness's own generator, not a second one of the same shape. This file used + to roll its own LCG, so the two guards described the same game with different dice and + no figure here could be reproduced from simulate.mjs. */ +const rngFor = makeRng; const winRate = (foes, targets, seed) => { let won = 0; for (let i = 0; i < RUNS; i++) { diff --git a/tools/focus-baseline.json b/tools/focus-baseline.json index c150248..c9985aa 100644 --- a/tools/focus-baseline.json +++ b/tools/focus-baseline.json @@ -20,66 +20,66 @@ "creatures": { "quiet_neighbours_npc": { "n": 6, - "spread": 47.4, - "focus": 55.7, - "gain": 8.3, - "noise": 2.5 + "spread": 47, + "focus": 54.9, + "gain": 7.9, + "noise": 2.7 }, "keepers": { "n": 5, - "spread": 59.9, - "focus": 70, - "gain": 10.1, - "noise": 0.5 + "spread": 60, + "focus": 69.8, + "gain": 9.8, + "noise": 2.7 }, "cleaner": { "n": 6, - "spread": 57.8, - "focus": 71, - "gain": 13.2, - "noise": 6.8 + "spread": 59.4, + "focus": 70.1, + "gain": 10.7, + "noise": 1.4 }, "cleaner_marksman": { "n": 5, - "spread": 51.2, - "focus": 62.1, - "gain": 10.9, - "noise": 0.7 + "spread": 51.8, + "focus": 61.9, + "gain": 10.1, + "noise": 0.5 }, "revenant": { "n": 3, - "spread": 63.1, - "focus": 73.3, - "gain": 10.2, - "noise": 1.6 + "spread": 61.9, + "focus": 75.4, + "gain": 13.5, + "noise": 2.2 }, "duplicate": { "n": 5, - "spread": 58.4, - "focus": 69.3, - "gain": 10.9, - "noise": 3.2 + "spread": 60.5, + "focus": 69.7, + "gain": 9.2, + "noise": 2.4 }, "barghest": { "n": 3, - "spread": 26.2, - "focus": 36.8, - "gain": 10.5, - "noise": 2.2 + "spread": 24.8, + "focus": 36.5, + "gain": 11.8, + "noise": 3.5 }, "kelpie": { "n": 3, - "spread": 54.6, - "focus": 67.3, - "gain": 12.8, - "noise": 1.8 + "spread": 52, + "focus": 67.1, + "gain": 15.1, + "noise": 2.8 }, "redcap": { "n": 3, - "spread": 31.4, - "focus": 44, - "gain": 12.6, - "noise": 2 + "spread": 31.1, + "focus": 41.9, + "gain": 10.9, + "noise": 1.8 }, "boggart": { "n": 2, @@ -87,18 +87,18 @@ }, "churchgrim": { "n": 3, - "spread": 42.1, - "focus": 54.7, - "gain": 12.6, - "noise": 3.4 + "spread": 43.7, + "focus": 55.7, + "gain": 12, + "noise": 2.1 }, "supporter": { "n": 2, - "pinned": 3.8 + "pinned": 4 }, "hollow_man": { "n": 6, - "pinned": 92.4 + "pinned": 91.8 }, "the_tenant": { "n": 2, @@ -106,57 +106,57 @@ }, "the_choir": { "n": 6, - "pinned": 99.1 + "pinned": 99.4 }, "precedent": { "n": 3, - "spread": 59.2, - "focus": 71.1, - "gain": 11.9, + "spread": 61, + "focus": 72.2, + "gain": 11.2, "noise": 0.7 }, "carrion_file": { "n": 4, - "spread": 45.8, - "focus": 58.4, - "gain": 12.7, - "noise": 3.6 + "spread": 45.2, + "focus": 57.3, + "gain": 12.1, + "noise": 1.3 }, "the_understudy": { "n": 5, - "spread": 60.2, - "focus": 71.8, - "gain": 11.7, - "noise": 0.8 + "spread": 61.5, + "focus": 73, + "gain": 11.5, + "noise": 6.3 }, "surveyor": { "n": 2, - "spread": 27.5, - "focus": 34.5, - "gain": 7, - "noise": 3.7 + "spread": 28.8, + "focus": 34.4, + "gain": 5.6, + "noise": 1.9 }, "quarantine_unit": { "n": 2, - "pinned": 0.1 + "pinned": 0.2 }, "cuckoo_array": { "n": 2, - "spread": 58.3, + "spread": 58.6, "focus": 66.2, - "gain": 7.9, - "noise": 2.1 + "gain": 7.7, + "noise": 3.8 }, "long_walker": { "n": 2, - "pinned": 0.6 + "pinned": 0.1 }, "auditor": { "n": 4, - "spread": 52, - "focus": 64.5, - "gain": 12.5, - "noise": 1.6 + "spread": 49.4, + "focus": 62.3, + "gain": 12.9, + "noise": 2.5 }, "washer_ford": { "n": 2, @@ -167,37 +167,37 @@ "pinned": 100 }, "grindylow": { - "n": 3, - "spread": 70.6, - "focus": 80.1, - "gain": 9.4, - "noise": 2.9 + "n": 4, + "spread": 30.5, + "focus": 42.1, + "gain": 11.6, + "noise": 1.5 }, "knockers": { - "n": 2, - "pinned": 100 + "n": 6, + "pinned": 99.7 }, "spriggan": { "n": 3, - "spread": 38.9, - "focus": 55.5, - "gain": 16.6, - "noise": 3.4 + "spread": 42.5, + "focus": 55.4, + "gain": 12.9, + "noise": 5.7 }, "nuckelavee": { "n": 2, - "pinned": 8.5 + "pinned": 8.2 }, "hedley_kow": { "n": 6, - "pinned": 97.6 + "pinned": 97.8 }, "apple_man": { "n": 2, - "spread": 36.8, - "focus": 42.5, - "gain": 5.7, - "noise": 7.2 + "spread": 36.6, + "focus": 42.1, + "gain": 5.5, + "noise": 0.8 }, "the_committee": { "n": 2, @@ -205,24 +205,24 @@ }, "night_registry": { "n": 4, - "spread": 62.1, - "focus": 76.2, - "gain": 14, + "spread": 63.1, + "focus": 73.9, + "gain": 10.7, "noise": 2.5 }, "the_locum": { "n": 5, - "spread": 49.3, - "focus": 61.5, - "gain": 12.2, - "noise": 2.4 + "spread": 49.2, + "focus": 63.1, + "gain": 13.9, + "noise": 1.9 }, "lost_property": { "n": 2, - "spread": 54.9, - "focus": 64.1, - "gain": 9.2, - "noise": 2.1 + "spread": 54.3, + "focus": 63.2, + "gain": 8.9, + "noise": 1.3 }, "the_minutes": { "n": 2, @@ -230,71 +230,74 @@ }, "waiting_room": { "n": 3, - "spread": 63.2, - "focus": 71.8, - "gain": 8.6, - "noise": 2.1 + "spread": 63.4, + "focus": 70.8, + "gain": 7.4, + "noise": 0.3 }, "the_predecessor": { "n": 4, - "spread": 55.5, - "focus": 69.2, - "gain": 13.7, - "noise": 6.7 + "spread": 58.6, + "focus": 71, + "gain": 12.5, + "noise": 5.1 }, "switchboard": { "n": 6, - "pinned": 86.1 + "pinned": 86.9 }, "the_census": { "n": 3, "spread": 64.3, - "focus": 75.3, - "gain": 11, - "noise": 4.2 + "focus": 76.5, + "gain": 12.1, + "noise": 1 }, "the_courier": { "n": 2, - "spread": 17.5, - "focus": 25.2, - "gain": 7.7, - "noise": 4 + "spread": 17.8, + "focus": 25, + "gain": 7.2, + "noise": 1.8 }, "the_relay": { "n": 2, - "spread": 52.6, - "focus": 58, - "gain": 5.4, - "noise": 6.7 + "spread": 52.7, + "focus": 58.9, + "gain": 6.2, + "noise": 2.8 }, "the_stanchion": { "n": 2, - "pinned": 4 + "pinned": 5.8 }, "the_yield": { "n": 2, - "spread": 48.7, - "focus": 62, - "gain": 13.3, - "noise": 3.8 + "spread": 46.8, + "focus": 57.8, + "gain": 11, + "noise": 1.5 }, "the_overwriter": { "n": 3, - "spread": 62.6, - "focus": 71.7, - "gain": 9.2, - "noise": 3.9 + "spread": 64.1, + "focus": 72.7, + "gain": 8.6, + "noise": 1.9 }, "the_arrears": { "n": 2, - "pinned": 14.3 + "spread": 14.7, + "focus": 21, + "gain": 6.3, + "noise": 1.5 }, "the_margin": { "n": 5, - "spread": 46.7, - "focus": 59.6, - "gain": 12.8, - "noise": 6.2 + "spread": 45.3, + "focus": 59.3, + "gain": 14.1, + "noise": 2.3 } } } diff --git a/tools/playthrough.mjs b/tools/playthrough.mjs index 8f57606..5f61de7 100644 --- a/tools/playthrough.mjs +++ b/tools/playthrough.mjs @@ -25,7 +25,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { NPCS } from "./content.mjs"; import { ROSTER } from "./roster.mjs"; -import { runFight } from "./simulate.mjs"; +import { runFight, makeRng } from "./simulate.mjs"; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); const base = JSON.parse(readFileSync(path.join(ROOT, "tools", "lethality-baseline.json"), "utf8")); @@ -59,8 +59,9 @@ const rec = focus.creatures[KEY]; defect this file exists to help find. Solo also matches what check-lethality records. */ const N = Number(opt("--count", 0)) || (rec?.gain !== undefined ? rec.n : 1); -/* The same generator shape the guards use, so a seed here names the same fight there. */ -const mk = seed => { let s = seed >>> 0; return () => ((s = (1664525 * s + 1013904223) >>> 0) / 4294967296); }; +/* THE generator the guards use, not merely one of the same shape. A seed here names the + first fight of `simulate.mjs --seed ` exactly. */ +const mk = makeRng; const firstName = c => (c.name ?? "?").split(" ")[0]; const hp = c => `${Math.max(0, c.hp)}/${c.hpMax}`; @@ -116,7 +117,7 @@ for (const mode of MODES) { : " — party picking targets at random"; console.log(`\n${"=".repeat(78)}\n${N} × ${spec.name.toUpperCase()}${how}\n${"=".repeat(78)}`); console.log(lines.join("\n")); - console.log(`\n → ${r.won ? "PARTY WINS" : r.wiped ? "PARTY WIPED" : "neither side finished it"}` + console.log(`\n → [${N > 1 ? mode : "solo"}] ${r.won ? "PARTY WINS" : r.wiped ? "PARTY WIPED" : "neither side finished it"}` + ` in ${r.rounds} rounds · ${r.partyDown} of ${party.length} down · ${r.dead} dead`); } diff --git a/tools/simulate.mjs b/tools/simulate.mjs index 021bdf7..08a50d7 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -98,7 +98,11 @@ export function seedFor(base, key = "") { } /** mulberry32 — small, fast, and seedable, which is the only property that matters. */ -function makeRng(seed) { +/* R-265. Exported because anything that cites a measured fight has to draw from the same + stream to name the same fight. tools/playthrough.mjs had its own LCG, so "seed 2" meant + one fight there and a different one here, which quietly broke the only thing that tool + exists for. */ +export function makeRng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0;