Seed the lethality sim per creature (R-252)

Each creature now derives its own seed from its key, FNV-1a mixed into
the base seed, instead of every one of them being measured against seed
11.

This is the second reading of the request and the one I had not tested.
What I checked before was position independence — whether a creature's
numbers depend on its neighbours — and that was already true and still
is: inserting a creature ahead of the barghest changes 0 of 47 existing
rows under the new scheme, same as the old. What was NOT true is that
each creature had its own dice. All forty-seven faced the same two
hundred sequences.

Measured, because the argument for doing it is better than the result:

                                  shared 11   per-creature
  mean wipe rate across bestiary     5.67%        5.67%
  mean agents down                   0.538        0.522
  rows changed                         --        34 of 47

Seed 11 was not biasing the book. There was no systematic luck to
remove, and the aggregate is unmoved to two decimal places. What the
change buys is decorrelation: the error in each row no longer comes from
the same draw as every other row.

The useful number fell out of the comparison rather than the change.
Individual creatures moved up to four points of wipe rate purely from
being handed different dice — the courier 3.5% -> 7.5%, the long walker
66.5% -> 62.5%, quarantine unit 84.5% -> 88.5%. That is the sampling
noise inside any single recorded figure at 200 runs, and it means these
numbers are an exact regression fingerprint and a loose description of a
creature at the same time. Only more runs narrows the second; more seeds
does not. R-252 says so on the page.

seedMode is recorded alongside the numbers and checked, because changing
how a seed is derived moves every row without changing SEED itself. A
baseline from the old scheme is now refused rather than compared against
this one silently and wrongly — verified by running the new code against
the old file before re-recording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-12 22:28:45 +01:00
co-authored by Claude Opus 5
parent e54ce9130d
commit 2dbea88d4b
5 changed files with 141 additions and 62 deletions
+42
View File
@@ -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.
+1 -1
View File
@@ -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"
+14 -7
View File
@@ -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 "
+55 -54
View File
@@ -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
}
}
+29
View File
@@ -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;