feat(rng): 实现 1.13c PRNG 64位 LCG 与 per-unit seed (Issue #87)

This commit is contained in:
troytt 2026-09-18 11:08:37 +00:00
parent 5d6dc8a15f
commit 928d42220c
2 changed files with 451 additions and 0 deletions

155
src/game/d2-rng.ts Normal file
View File

@ -0,0 +1,155 @@
/**
* 1.13c Diablo II Linear Congruential Generator (LCG) PRNG and Per-Unit Seed.
*
* Gold standard references:
* - D2Common!6FD510B0 (SEED_RollRandomNumber / SEED_RollLimitedRandomNumber)
* - D2Common!6FD51180 (SEED_InitLowSeed / SEED_InitSeed)
* - D2Game!6FC211D0 (Unit seed operations / UNITS_RollRandom)
*
* In Diablo II 1.13c, PRNG state is maintained as a 64-bit value split into
* two 32-bit registers (nLowSeed and nHighSeed). The recurrence relation is:
* state64 = (uint64_t)nLowSeed * 0x6AC690C5 + nHighSeed
* nLowSeed = (uint32_t)state64
* nHighSeed = (uint32_t)(state64 >> 32)
*
* Each unit (monsters, players, items, missiles) encapsulates its own seed state,
* preventing cross-entity RNG stream contamination and ensuring lockstep determinism.
*/
/** Canonical 64-bit LCG multiplier in Diablo II 1.13c (0x6AC690C5 = 1791398085). */
export const D2_PRNG_MULTIPLIER = 0x6ac690c5n
/** Seed state representing the low and high 32-bit registers. */
export interface D2SeedState {
lo: number
hi: number
}
/**
* 1.13c Diablo II PRNG implementation with per-unit seed encapsulation.
*/
export class D2Rng {
private _lo = 0
private _hi = 0
/**
* Initializes a new PRNG instance.
*
* @param seed - Optional initial 32-bit low seed or { lo, hi } state object.
* @param hi - Optional initial 32-bit high seed (defaults to 0 if not provided).
*/
constructor(seed?: number | D2SeedState, hi?: number) {
if (seed !== undefined) {
if (typeof seed === 'number') {
this.setSeed(seed, hi)
} else {
this.setSeed(seed.lo, seed.hi ?? hi)
}
}
}
/** Current 32-bit low seed register. */
get lo(): number {
return this._lo
}
/** Current 32-bit high seed register. */
get hi(): number {
return this._hi
}
/**
* Updates the seed state registers.
*
* @param lo - Low 32-bit seed value.
* @param hi - High 32-bit seed value (defaults to 0).
*/
setSeed(lo: number, hi?: number): void {
this._lo = lo >>> 0
this._hi = (hi ?? 0) >>> 0
}
/**
* Returns the current seed state as a { lo, hi } pair.
*/
getSeed(): D2SeedState {
return { lo: this._lo, hi: this._hi }
}
/**
* Advances the 64-bit LCG state by one step:
* state64 = BigInt(lo) * 0x6AC690C5n + BigInt(hi)
* lo = Number(state64 & 0xFFFFFFFFn) >>> 0
* hi = Number((state64 >> 32n) & 0xFFFFFFFFn) >>> 0
*/
step(): void {
const state64 = BigInt(this._lo) * D2_PRNG_MULTIPLIER + BigInt(this._hi)
this._lo = Number(state64 & 0xffffffffn) >>> 0
this._hi = Number((state64 >> 32n) & 0xffffffffn) >>> 0
}
/**
* Rolls a pseudo-random integer in `[0, max - 1]`.
*
* If max <= 0, returns 0 without advancing state.
* Otherwise advances state (`step()`).
* If max is a power of 2 (i.e. `(max & (max - 1)) === 0`):
* returns `(lo & (max - 1)) >>> 0`
* Else:
* returns `(lo % max) >>> 0`
*
* @param max - Exclusive upper bound.
* @returns Generated random integer in `[0, max - 1]`, or 0 if max <= 0.
*/
rand(max: number): number {
if (max <= 0) {
return 0
}
this.step()
if ((max & (max - 1)) === 0) {
return (this._lo & (max - 1)) >>> 0
}
return (this._lo % max) >>> 0
}
/**
* Rolls a pseudo-random integer in inclusive range `[min, max]`.
*
* If min >= max, returns min without advancing state.
* Otherwise span = max - min + 1, advances state (`step()`),
* and returns `min + ((lo % span) >>> 0)`.
*
* @param min - Lower bound (inclusive).
* @param max - Upper bound (inclusive).
* @returns Random integer in [min, max].
*/
randRange(min: number, max: number): number {
if (min >= max) {
return min
}
const span = max - min + 1
this.step()
return min + ((this._lo % span) >>> 0)
}
/**
* Creates an independent copy of this PRNG instance preserving the exact current seed state.
*/
clone(): D2Rng {
const copy = new D2Rng()
copy.setSeed(this._lo, this._hi)
return copy
}
/**
* Advances state and returns the updated 32-bit low seed value (matching SEED_RollRandomNumber).
*/
next(): number {
this.step()
return this._lo
}
}
/** Alias for D2Rng to represent per-unit seed instances. */
export const UnitSeed = D2Rng
export type UnitSeed = D2Rng

296
tests/d2-rng.test.ts Normal file
View File

@ -0,0 +1,296 @@
import { describe, expect, test } from "vitest"
import { D2Rng, UnitSeed, D2_PRNG_MULTIPLIER } from "../src/game/d2-rng.ts"
describe("1.13c Diablo II PRNG and Per-Unit Seed (D2Common!6FD510B0 / D2Game!6FC211D0)", () => {
describe("64-bit LCG state recurrence and known seed vectors", () => {
test("has the canonical 1.13c multiplier 0x6AC690C5 (1791398085)", () => {
expect(D2_PRNG_MULTIPLIER).toBe(0x6ac690c5n)
expect(Number(D2_PRNG_MULTIPLIER)).toBe(1791398085)
})
test("reproduces exact bitwise sequence for known vector lo = 1, hi = 0 across 10 steps", () => {
const rng = new D2Rng({ lo: 1, hi: 0 })
expect(rng.lo).toBe(1)
expect(rng.hi).toBe(0)
const expectedSteps = [
{ lo: 1791398085, hi: 0 },
{ lo: 1721382809, hi: 747178471 },
{ lo: 986833572, hi: 717975634 },
{ lo: 2963478662, hi: 411600752 },
{ lo: 930727566, hi: 1236044336 },
{ lo: 3622188406, hi: 388199365 },
{ lo: 4003526547, hi: 1510787143 },
{ lo: 3772775526, hi: 1669840372 },
{ lo: 3770833010, hi: 1573595882 },
{ lo: 1095064228, hi: 1572785674 },
]
for (let i = 0; i < expectedSteps.length; i++) {
rng.step()
expect(rng.getSeed()).toEqual(expectedSteps[i])
}
// Re-running with a new instance yields exact same bitwise reproducibility
const freshRng = new D2Rng(1, 0)
for (let i = 0; i < expectedSteps.length; i++) {
freshRng.step()
expect(freshRng.lo).toBe(expectedSteps[i].lo)
expect(freshRng.hi).toBe(expectedSteps[i].hi)
}
})
test("matches 1.13c SEED_InitLowSeed / rollD2Random vector lo = 100, hi = 666", () => {
const rng = new D2Rng({ lo: 100, hi: 666 })
// 666 + 0x6AC690C5 * 100 = 179139809166
// low 32 bits = 3046150030
// high 32 bits = 41
rng.step()
expect(rng.lo).toBe(3046150030)
expect(rng.hi).toBe(41)
})
})
describe("Random generation rand(max)", () => {
test("power-of-2 branch uses bitwise AND: (lo & (max - 1)) >>> 0", () => {
const rng = new D2Rng(42, 0)
// Test power of 2: 1024 (1024 & 1023 === 0)
const roll = rng.rand(1024)
const expected = (rng.lo & 1023) >>> 0
expect(roll).toBe(expected)
expect(roll).toBeGreaterThanOrEqual(0)
expect(roll).toBeLessThan(1024)
// Test power of 2: 16 (16 & 15 === 0)
const roll16 = rng.rand(16)
expect(roll16).toBe((rng.lo & 15) >>> 0)
expect(roll16).toBeGreaterThanOrEqual(0)
expect(roll16).toBeLessThan(16)
// Test power of 2: 2 (2 & 1 === 0)
const roll2 = rng.rand(2)
expect(roll2).toBe((rng.lo & 1) >>> 0)
expect(roll2 === 0 || roll2 === 1).toBe(true)
// Test power of 2: 1 (1 & 0 === 0)
const roll1 = rng.rand(1)
expect(roll1).toBe(0)
})
test("non-power-of-2 branch uses modulo: (lo % max) >>> 0", () => {
const rng = new D2Rng(42, 0)
// Test non-power of 2: 1000 (1000 & 999 !== 0)
const roll = rng.rand(1000)
const expected = (rng.lo % 1000) >>> 0
expect(roll).toBe(expected)
expect(roll).toBeGreaterThanOrEqual(0)
expect(roll).toBeLessThan(1000)
// Test non-power of 2: 100
const roll100 = rng.rand(100)
expect(roll100).toBe((rng.lo % 100) >>> 0)
expect(roll100).toBeGreaterThanOrEqual(0)
expect(roll100).toBeLessThan(100)
// Test non-power of 2: 7
const roll7 = rng.rand(7)
expect(roll7).toBe((rng.lo % 7) >>> 0)
expect(roll7).toBeGreaterThanOrEqual(0)
expect(roll7).toBeLessThan(7)
})
test("returns 0 and does not advance state when max <= 0", () => {
const rng = new D2Rng(999, 123)
const initialSeed = rng.getSeed()
expect(rng.rand(0)).toBe(0)
expect(rng.getSeed()).toEqual(initialSeed)
expect(rng.rand(-1)).toBe(0)
expect(rng.getSeed()).toEqual(initialSeed)
expect(rng.rand(-100)).toBe(0)
expect(rng.getSeed()).toEqual(initialSeed)
})
})
describe("Range generation randRange(min, max)", () => {
test("returns min without advancing state when min >= max", () => {
const rng = new D2Rng(500, 100)
const initialSeed = rng.getSeed()
// min > max
expect(rng.randRange(10, 5)).toBe(10)
expect(rng.getSeed()).toEqual(initialSeed)
// min === max
expect(rng.randRange(7, 7)).toBe(7)
expect(rng.getSeed()).toEqual(initialSeed)
})
test("generates values within [min, max] when min < max", () => {
const rng = new D2Rng(12345, 678)
for (let i = 0; i < 50; i++) {
const loBefore = rng.lo
const val = rng.randRange(1, 10)
// State must have advanced
expect(rng.lo).not.toBe(loBefore)
// Range check
expect(val).toBeGreaterThanOrEqual(1)
expect(val).toBeLessThanOrEqual(10)
// Check formula: span = 10, min + (lo % span)
expect(val).toBe(1 + ((rng.lo % 10) >>> 0))
}
})
test("handles negative ranges correctly", () => {
const rng = new D2Rng(1, 0)
for (let i = 0; i < 30; i++) {
const val = rng.randRange(-10, -5)
expect(val).toBeGreaterThanOrEqual(-10)
expect(val).toBeLessThanOrEqual(-5)
const span = -5 - (-10) + 1 // 6
expect(val).toBe(-10 + ((rng.lo % span) >>> 0))
}
})
test("simulates standard 6-sided dice roll randRange(1, 6)", () => {
const rng = new D2Rng(888)
const rolled = new Set<number>()
for (let i = 0; i < 100; i++) {
const roll = rng.randRange(1, 6)
expect(roll).toBeGreaterThanOrEqual(1)
expect(roll).toBeLessThanOrEqual(6)
rolled.add(roll)
}
// Over 100 rolls, all sides 1-6 should appear
for (let side = 1; side <= 6; side++) {
expect(rolled.has(side)).toBe(true)
}
})
})
describe("Per-unit seed independence (UnitSeed)", () => {
test("UnitSeed alias points to D2Rng", () => {
expect(UnitSeed).toBe(D2Rng)
const unit = new UnitSeed(123)
expect(unit instanceof D2Rng).toBe(true)
})
test("separate unit seeds do not affect each other", () => {
const unitA = new D2Rng(100, 0)
const unitB = new D2Rng(200, 0)
const initialB = unitB.getSeed()
// Advance unitA 25 times
for (let i = 0; i < 25; i++) {
unitA.rand(100)
}
// unitB must remain completely untouched
expect(unitB.getSeed()).toEqual(initialB)
// Advance unitB once
const valB1 = unitB.rand(100)
// Now create a fresh unit with initialB seed
const unitBClone = new D2Rng(200, 0)
const expectedValB1 = unitBClone.rand(100)
expect(valB1).toBe(expectedValB1)
expect(unitB.getSeed()).toEqual(unitBClone.getSeed())
})
test("interleaved execution between units produces identical results to sequential execution", () => {
// Sequential run
const unit1Seq = new D2Rng(111, 222)
const unit2Seq = new D2Rng(333, 444)
const seq1Rolls = Array.from({ length: 10 }, () => unit1Seq.rand(50))
const seq2Rolls = Array.from({ length: 10 }, () => unit2Seq.rand(50))
// Interleaved run
const unit1Interleaved = new D2Rng(111, 222)
const unit2Interleaved = new D2Rng(333, 444)
const inter1Rolls: number[] = []
const inter2Rolls: number[] = []
for (let i = 0; i < 10; i++) {
inter1Rolls.push(unit1Interleaved.rand(50))
inter2Rolls.push(unit2Interleaved.rand(50))
}
expect(inter1Rolls).toEqual(seq1Rolls)
expect(inter2Rolls).toEqual(seq2Rolls)
expect(unit1Interleaved.getSeed()).toEqual(unit1Seq.getSeed())
expect(unit2Interleaved.getSeed()).toEqual(unit2Seq.getSeed())
})
})
describe("Instance lifecycle, clone, and state mutation", () => {
test("constructor accepts undefined, number, or { lo, hi }", () => {
const empty = new D2Rng()
expect(empty.getSeed()).toEqual({ lo: 0, hi: 0 })
const fromNum = new D2Rng(12345)
expect(fromNum.getSeed()).toEqual({ lo: 12345, hi: 0 })
const fromNumAndHi = new D2Rng(12345, 678)
expect(fromNumAndHi.getSeed()).toEqual({ lo: 12345, hi: 678 })
const fromObj = new D2Rng({ lo: 555, hi: 999 })
expect(fromObj.getSeed()).toEqual({ lo: 555, hi: 999 })
})
test("setSeed masks values to unsigned 32-bit integers", () => {
const rng = new D2Rng()
rng.setSeed(-1, -1)
expect(rng.lo).toBe(0xffffffff)
expect(rng.hi).toBe(0xffffffff)
expect(rng.getSeed()).toEqual({ lo: 4294967295, hi: 4294967295 })
})
test("clone() creates an exact, independent copy", () => {
const original = new D2Rng(777, 888)
original.step()
const cloned = original.clone()
expect(cloned.getSeed()).toEqual(original.getSeed())
// Stepping cloned does not affect original
const clonedSeedBefore = cloned.getSeed()
original.step()
expect(cloned.getSeed()).toEqual(clonedSeedBefore)
expect(original.getSeed()).not.toEqual(cloned.getSeed())
// Next roll from clone produces expected value
const cloneRoll = cloned.rand(100)
const expectedOriginalClone = new D2Rng(clonedSeedBefore)
expect(cloneRoll).toBe(expectedOriginalClone.rand(100))
})
test("getSeed() returns a new object preventing external mutation of internal state", () => {
const rng = new D2Rng(100, 200)
const seed = rng.getSeed()
seed.lo = 9999
seed.hi = 9999
expect(rng.lo).toBe(100)
expect(rng.hi).toBe(200)
})
test("next() advances state and returns low 32-bit register", () => {
const rng = new D2Rng(1, 0)
const val = rng.next()
expect(val).toBe(1791398085)
expect(rng.lo).toBe(1791398085)
expect(rng.hi).toBe(0)
})
})
})