diablo2-web/src/game/d2-rng.ts

156 lines
4.3 KiB
TypeScript

/**
* 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