diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 317bddb..d827047 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -5175,3 +5175,45 @@ Two corrections to the record, both mine, both from explaining before measuring: The baseline recorded in `e5dc9b5` already carries these numbers. What was missing was this page. + +### R-252. One seed's luck, spread across forty-seven creatures + +Every creature was measured against seed 11. That made each one independent of its +neighbours — the property I had wrongly claimed was broken in R-251 — but it also gave +all forty-seven of them the *same two hundred dice*. Independent of position, identical +in luck. + +Each creature now derives its own seed from its key (FNV-1a mixed into the base seed), +which is still deterministic, still reproducible from the base seed alone, and still +local: adding a creature changes 0 of 47 existing rows, measured, exactly as before. + +What it was worth is smaller than the argument for it, and worth writing down honestly: + +| | shared seed 11 | per-creature | +|---|---|---| +| mean wipe rate across the bestiary | 5.67% | **5.67%** | +| mean agents down | 0.538 | 0.522 | +| rows that changed | — | 34 of 47 | + +So seed 11 was **not** biasing the book. There was no systematic luck to remove, and +anyone expecting the creatures to look collectively softer or harder afterwards would +have been disappointed. What the change does is decorrelate: the errors in individual +rows no longer all come from the same draw. + +The more useful number fell out of the comparison. Individual creatures moved by up to +four points of wipe rate — the courier 3.5% → 7.5%, the long walker 66.5% → 62.5%, +quarantine unit 84.5% → 88.5% — purely from being handed different dice. That is the +size of the sampling noise sitting inside any single recorded figure at 200 runs. + +Which means the two things this file records must be read differently, and the header +has always said so: + +- As a **regression fingerprint** the numbers are exact and now compared exactly. Any + movement is a real change to the rules or the creature. That is what the guard is for. +- As a **description of a creature** a single figure carries several points of slack. + "The courier wipes 7.5% of parties" is one sample, not a property. Raising the run + count is the only thing that narrows it; more seeds does not. + +`seedMode` is recorded alongside the numbers, because changing how a seed is derived +moves every row without changing `SEED` itself, and a baseline from the old scheme would +otherwise have been compared against this one silently and wrongly. It is refused now. diff --git a/system.json b/system.json index 0e67423..52e3c6a 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.7.6", + "version": "1.7.7", "compatibility": { "minimum": 13, "verified": "14.364" diff --git a/tools/check-lethality.mjs b/tools/check-lethality.mjs index 4f66783..8cd118c 100644 --- a/tools/check-lethality.mjs +++ b/tools/check-lethality.mjs @@ -32,7 +32,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { NPCS } from "./content.mjs"; import { ROSTER } from "./roster.mjs"; -import { measure } from "./simulate.mjs"; +import { measure, seedFor } 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", "lethality-baseline.json"); @@ -46,6 +46,10 @@ const VERBOSE = argv.includes("--verbose"); const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"]; const RUNS = 200; const SEED = 11; +/* Recorded alongside the numbers, because changing HOW the seed is derived changes every + row without changing SEED itself — a baseline taken under the old shared-seed scheme + would otherwise be compared, silently and wrongly, against this one. */ +const SEED_MODE = "per-creature"; /* NO TOLERANCE, deliberately. The reasoning it replaces sounded right — "200 runs of a coin flip has a standard error around 3.5 points, so anything under 6 is inside the @@ -76,7 +80,7 @@ const party = PARTY_KEYS.map(k => { /** Solo, because a creature is the unit under test — counts are an encounter's business. */ const current = {}; for (const spec of NPCS) { - const r = measure(party, [spec], { runs: RUNS, seed: SEED }); + const r = measure(party, [spec], { runs: RUNS, seed: seedFor(SEED, spec.key) }); current[spec.key] = { wipe: Number((r.wipeRate * 100).toFixed(1)), down: Number(r.downMean.toFixed(2)), @@ -87,11 +91,12 @@ for (const spec of NPCS) { if (UPDATE) { await writeFile(BASELINE, JSON.stringify({ note: "Generated by tools/check-lethality.mjs --update. Do not edit by hand.", - party: PARTY_KEYS, runs: RUNS, seed: SEED, + party: PARTY_KEYS, runs: RUNS, seed: SEED, seedMode: SEED_MODE, creatures: current }, null, 2) + "\n", "utf8"); console.log(`check-lethality: baseline recorded — ${Object.keys(current).length} creatures, ` - + `party ${PARTY_KEYS.map(k => k.replace(/^pc_/, "")).join(", ")}, ${RUNS} runs, seed ${SEED}`); + + `party ${PARTY_KEYS.map(k => k.replace(/^pc_/, "")).join(", ")}, ${RUNS} runs, ` + + `seed ${SEED} ${SEED_MODE}`); process.exit(0); } @@ -105,9 +110,11 @@ const base = JSON.parse(await readFile(BASELINE, "utf8")); /* The recorded numbers mean nothing if they were taken under different conditions, and a guard comparing incomparable numbers is worse than no guard. */ if (base.runs !== RUNS || base.seed !== SEED + || base.seedMode !== SEED_MODE || base.party.join() !== PARTY_KEYS.join()) { console.error("check-lethality: FAILED — the baseline was recorded under different conditions " - + `(party ${base.party.join(",")}, ${base.runs} runs, seed ${base.seed}). Re-record it with --update.`); + + `(party ${base.party.join(",")}, ${base.runs} runs, seed ${base.seed} ` + + `${base.seedMode ?? "shared"}). Re-record it with --update.`); process.exit(1); } @@ -117,8 +124,8 @@ if (base.runs !== RUNS || base.seed !== SEED letting the whole guard degrade into noise-chasing. */ { const probe = NPCS[0]; - const a = measure(party, [probe], { runs: RUNS, seed: SEED }); - const b = measure(party, [probe], { runs: RUNS, seed: SEED }); + const a = measure(party, [probe], { runs: RUNS, seed: seedFor(SEED, probe.key) }); + const b = measure(party, [probe], { runs: RUNS, seed: seedFor(SEED, probe.key) }); const shape = r => JSON.stringify([r.wipeRate, r.downMean, r.roundsMedian, r.hurtMean]); if (shape(a) !== shape(b)) { console.error("check-lethality: FAILED — the simulation is not deterministic, so an exact " diff --git a/tools/lethality-baseline.json b/tools/lethality-baseline.json index 69bd779..bc61f6d 100644 --- a/tools/lethality-baseline.json +++ b/tools/lethality-baseline.json @@ -8,10 +8,11 @@ ], "runs": 200, "seed": 11, + "seedMode": "per-creature", "creatures": { "quiet_neighbours_npc": { "wipe": 0, - "down": 0.01, + "down": 0.02, "rounds": 2 }, "keepers": { @@ -21,7 +22,7 @@ }, "cleaner": { "wipe": 0, - "down": 0.04, + "down": 0.02, "rounds": 2 }, "cleaner_marksman": { @@ -31,43 +32,43 @@ }, "revenant": { "wipe": 0, - "down": 0.14, - "rounds": 3 + "down": 0.16, + "rounds": 4 }, "duplicate": { "wipe": 0, - "down": 0.04, - "rounds": 2 + "down": 0.07, + "rounds": 3 }, "barghest": { "wipe": 0, - "down": 0.48, + "down": 0.39, "rounds": 4 }, "kelpie": { "wipe": 0, - "down": 0.21, + "down": 0.23, "rounds": 3 }, "redcap": { "wipe": 0, - "down": 0.3, + "down": 0.34, "rounds": 3 }, "boggart": { "wipe": 0, "down": 0, - "rounds": 1 + "rounds": 2 }, "churchgrim": { "wipe": 0, - "down": 0.26, + "down": 0.33, "rounds": 4 }, "supporter": { - "wipe": 47, - "down": 2.82, - "rounds": 15 + "wipe": 44, + "down": 2.81, + "rounds": 14 }, "hollow_man": { "wipe": 0, @@ -87,47 +88,47 @@ "precedent": { "wipe": 0, "down": 0.16, - "rounds": 4 + "rounds": 3 }, "carrion_file": { "wipe": 0, - "down": 0.06, + "down": 0.04, "rounds": 5 }, "the_understudy": { "wipe": 0, - "down": 0.01, - "rounds": 2 + "down": 0, + "rounds": 3 }, "surveyor": { - "wipe": 8, - "down": 1.4, - "rounds": 11 + "wipe": 5.5, + "down": 1.44, + "rounds": 10 }, "quarantine_unit": { - "wipe": 84.5, - "down": 3.64, + "wipe": 88.5, + "down": 3.73, "rounds": 8 }, "cuckoo_array": { - "wipe": 1, - "down": 0.81, + "wipe": 3, + "down": 0.68, "rounds": 8 }, "long_walker": { - "wipe": 66.5, - "down": 3.35, - "rounds": 16 + "wipe": 62.5, + "down": 3.13, + "rounds": 15 }, "auditor": { "wipe": 0, - "down": 0.12, + "down": 0.09, "rounds": 3 }, "washer_ford": { "wipe": 0, "down": 0, - "rounds": 1 + "rounds": 2 }, "lantern_man": { "wipe": 0, @@ -146,12 +147,12 @@ }, "spriggan": { "wipe": 0, - "down": 0.33, + "down": 0.28, "rounds": 4 }, "nuckelavee": { - "wipe": 15.5, - "down": 1.75, + "wipe": 13.5, + "down": 1.64, "rounds": 7 }, "hedley_kow": { @@ -160,9 +161,9 @@ "rounds": 2 }, "apple_man": { - "wipe": 1.5, + "wipe": 2.5, "down": 1.02, - "rounds": 12 + "rounds": 10 }, "the_committee": { "wipe": 0, @@ -171,18 +172,18 @@ }, "night_registry": { "wipe": 0, - "down": 0.09, + "down": 0.07, "rounds": 3 }, "the_locum": { "wipe": 0, - "down": 0.04, + "down": 0.05, "rounds": 2 }, "lost_property": { - "wipe": 0.5, - "down": 0.79, - "rounds": 8 + "wipe": 1, + "down": 0.74, + "rounds": 7 }, "the_minutes": { "wipe": 0, @@ -196,7 +197,7 @@ }, "the_predecessor": { "wipe": 0, - "down": 0.09, + "down": 0.12, "rounds": 2 }, "switchboard": { @@ -206,42 +207,42 @@ }, "the_census": { "wipe": 0, - "down": 0.17, + "down": 0.12, "rounds": 4 }, "the_courier": { - "wipe": 3.5, - "down": 1.3, + "wipe": 7.5, + "down": 1.39, "rounds": 9 }, "the_relay": { - "wipe": 0.5, - "down": 0.67, + "wipe": 0, + "down": 0.59, "rounds": 10 }, "the_stanchion": { - "wipe": 28, - "down": 2.27, + "wipe": 31, + "down": 2.17, "rounds": 18 }, "the_yield": { - "wipe": 0.5, - "down": 0.76, + "wipe": 1.5, + "down": 0.7, "rounds": 6 }, "the_overwriter": { "wipe": 0, - "down": 0.17, + "down": 0.09, "rounds": 6 }, "the_arrears": { - "wipe": 9.5, - "down": 1.53, + "wipe": 6, + "down": 1.48, "rounds": 10 }, "the_margin": { "wipe": 0, - "down": 0.02, + "down": 0.01, "rounds": 3 } } diff --git a/tools/simulate.mjs b/tools/simulate.mjs index df5b846..b39ff39 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -67,6 +67,35 @@ const READINESS_INT_FACTOR = 3; /* ---------------------------------------------------------------- randomness */ +/** + * A seed of this creature's own. + * + * Every creature used to be measured against seed 11, which made each one independent + * of its neighbours — inserting a creature perturbs nobody — but gave all forty-seven + * of them the SAME two hundred dice. One draw's luck was therefore repeated across the + * whole bestiary: if seed 11 happens to roll cold for the party, every creature in the + * book looks a little deadlier than it is, and the error points the same way in all of + * them, so no amount of reading across the table reveals it. + * + * Mixing the creature's key into the base seed decorrelates them without giving up + * anything the guard depends on. It is still deterministic, still reproducible from the + * base seed alone, and still local: a creature's number is a function of its own key, + * so adding, removing or reordering the bestiary leaves every other row untouched. + * Renaming a key does change that creature's dice, which is correct — a renamed key is + * a different row in the baseline anyway. + * + * FNV-1a, because it is four lines and the only property that matters is that different + * keys land far apart. + */ +export function seedFor(base, key = "") { + let h = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return ((h ^ (Number(base) >>> 0)) >>> 0); +} + /** mulberry32 — small, fast, and seedable, which is the only property that matters. */ function makeRng(seed) { let a = seed >>> 0;