diablo2-web/src/game/rng.ts

125 lines
3.4 KiB
TypeScript

/**
* Deterministic pseudo-random numbers.
*
* Every random decision in the game — drops, affix rolls, monster spawn jitter —
* goes through one of these, seeded from a value the caller controls. That is not
* about quality (this is not a cryptographic generator) but about *reproducibility*:
* a drop table that cannot be replayed cannot be tested, and a simulation whose
* randomness comes from `Math.random` can never be reconciled between two machines,
* which is exactly what lockstep multiplayer needs at M5.
*
* The algorithm is mulberry32: 32 bits of state, a handful of integer ops, and a
* period long enough for a game session. It is written here rather than pulled in
* because the whole engine has no runtime dependencies.
*/
/** A seeded random source. */
export class Rng {
private state: number
/**
* @param seed - any 32-bit integer; the same seed replays the same sequence.
*/
constructor(seed: number) {
this.state = seed >>> 0
}
/**
* The generator's current state.
*
* Exposed so a save can capture the stream exactly: restoring a game without its
* random state would replay the same drops in a different order, which is the
* kind of difference that only shows up much later.
*/
get seed(): number {
return this.state
}
/**
* Draw the next value.
*
* @returns a number in `[0, 1)`.
*/
next(): number {
this.state = (this.state + 0x6d2b79f5) >>> 0
let t = this.state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
/**
* Draw an integer in an inclusive range.
*
* @param min - lower bound.
* @param max - upper bound (inclusive).
* @returns the integer.
*/
int(min: number, max: number): number {
if (max <= min) return Math.floor(min)
return Math.floor(min + this.next() * (max - min + 1))
}
/**
* Draw a floating-point value in a range.
*
* @param min - lower bound.
* @param max - upper bound.
* @returns the value.
*/
range(min: number, max: number): number {
return min + this.next() * (max - min)
}
/**
* Draw one element.
*
* @param items - the candidates.
* @returns the element, or undefined when there are none.
*/
pick<T>(items: readonly T[]): T | undefined {
if (items.length === 0) return undefined
return items[Math.floor(this.next() * items.length)]
}
/**
* Decide a yes/no with a probability.
*
* @param chance - probability in `[0, 1]`.
* @returns true when the draw succeeds.
*/
chance(probability: number): boolean {
return this.next() < probability
}
/**
* Derive an independent stream, so one subsystem's draws cannot shift another's.
*
* @param label - a stable name for the stream.
* @returns a new generator.
*/
fork(label: string): Rng {
let hash = 2166136261
for (let index = 0; index < label.length; index += 1) {
hash ^= label.charCodeAt(index)
hash = Math.imul(hash, 16777619) >>> 0
}
return new Rng((hash ^ this.state) >>> 0)
}
}
/**
* A stable 32-bit hash of a string, for seeding streams from names.
*
* @param text - the input.
* @returns the hash.
*/
export function hashString32(text: string): number {
let hash = 2166136261
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index)
hash = Math.imul(hash, 16777619) >>> 0
}
return hash >>> 0
}