One generator, so a citation names one fight (R-265)

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 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
LCG written to look like it. Both deterministic, both reproducible alone, and "seed 2"
named a different fight in each — which breaks the only thing the tool is for. Its own
comment claimed a seed here names the same fight there. check-focus carried a third copy of
that LCG, so the two 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 <n>: --runs 1 --seed 2 and the playthrough give 9 rounds, 4 of
4 down, 1 dead, both.

The other half was my citation rather than 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.

check-focus re-recorded under the shared stream; figures move a point or two. What it buys
is that the bimodality analysis now reproduces the published means exactly — 2.31, 3.03,
0.40, 0.42 against the four rows CLEAN GROUND publishes. Under the old LCG it agreed to
within a decimal, which looked like corroboration and was two experiments landing near each
other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-13 01:11:41 +01:00
co-authored by Claude Opus 5
parent 796bb7df73
commit 8627fa3536
8 changed files with 201 additions and 146 deletions
+5 -2
View File
@@ -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++) {
+132 -129
View File
@@ -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
}
}
}
+5 -4
View File
@@ -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 <n>` 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`);
}
+5 -1
View File
@@ -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;