aboutsummaryrefslogtreecommitdiff
path: root/rng/seeded.ts
blob: 6d9f9916352fb965242828124fea7d6b81118822 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import { deriveSeed, hashString } from '@hollowdark/rng/derive'

/**
 * Seeded PRNG. All gameplay randomness routes through this interface —
 * Math.random is forbidden in gameplay code (see the ESLint rule).
 *
 * Guarantees:
 *   - Same seed produces the same infinite sequence, bit-for-bit.
 *   - `sub(label)` produces a deterministic child stream, independent of
 *     how the parent stream is consumed.
 *   - Byte-level reproducibility across runs and machines.
 */
export interface SeededRNG {
  readonly seed: number
  /** Float in [0, 1). */
  next(): number
  /** Integer in [min, max], both inclusive. */
  nextInt(min: number, max: number): number
  /** Bernoulli draw with the given success probability. */
  nextBool(probability: number): boolean
  /** Uniform choice from a non-empty array. */
  pick<T>(items: readonly T[]): T
  /** Weighted choice from a non-empty list of (value, weight) tuples. */
  weightedPick<T>(items: readonly (readonly [T, number])[]): T
  /** Derive a new, independent RNG keyed by label and this RNG's seed. */
  sub(label: string): SeededRNG
}

/**
 * mulberry32 — 32-bit PRNG. Small, fast, and has good statistical
 * properties for game-scale use. Not cryptographic; not intended to be.
 */
function mulberry32(seed: number): () => number {
  let state = seed >>> 0
  return () => {
    state = (state + 0x6d2b79f5) | 0
    let t = Math.imul(state ^ (state >>> 15), 1 | state)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

class SeededRNGImpl implements SeededRNG {
  readonly seed: number
  readonly #gen: () => number

  constructor(seed: number) {
    this.seed = seed >>> 0
    this.#gen = mulberry32(this.seed)
  }

  next(): number {
    return this.#gen()
  }

  nextInt(min: number, max: number): number {
    if (!Number.isFinite(min) || !Number.isFinite(max)) {
      throw new Error(`rng.nextInt: bounds must be finite (got ${min}, ${max})`)
    }
    const lo = Math.ceil(min)
    const hi = Math.floor(max)
    if (hi < lo) {
      throw new Error(`rng.nextInt: empty range [${min}, ${max}]`)
    }
    return lo + Math.floor(this.#gen() * (hi - lo + 1))
  }

  nextBool(probability: number): boolean {
    if (probability < 0 || probability > 1 || !Number.isFinite(probability)) {
      throw new Error(`rng.nextBool: probability must be in [0, 1] (got ${probability})`)
    }
    return this.#gen() < probability
  }

  pick<T>(items: readonly T[]): T {
    if (items.length === 0) {
      throw new Error('rng.pick: items is empty')
    }
    const idx = Math.floor(this.#gen() * items.length)
    return items[idx] as T
  }

  weightedPick<T>(items: readonly (readonly [T, number])[]): T {
    if (items.length === 0) {
      throw new Error('rng.weightedPick: items is empty')
    }
    let total = 0
    for (const [, weight] of items) {
      if (!Number.isFinite(weight) || weight < 0) {
        throw new Error(`rng.weightedPick: invalid weight ${weight}`)
      }
      total += weight
    }
    if (total <= 0) {
      throw new Error('rng.weightedPick: total weight is zero')
    }
    const roll = this.#gen() * total
    let cumulative = 0
    for (const [value, weight] of items) {
      cumulative += weight
      if (roll < cumulative) return value
    }
    return items[items.length - 1]![0]
  }

  sub(label: string): SeededRNG {
    return new SeededRNGImpl(deriveSeed(this.seed, label))
  }
}

/**
 * Create a new seeded RNG. Accepts a string (which is hashed) or a number
 * (used directly as a uint32 seed).
 */
export function createRNG(seed: string | number): SeededRNG {
  const numeric = typeof seed === 'number' ? seed >>> 0 : hashString(seed)
  return new SeededRNGImpl(numeric)
}