diff --git a/src/sim/rng.ts b/src/sim/rng.ts new file mode 100644 index 0000000..603494e --- /dev/null +++ b/src/sim/rng.ts @@ -0,0 +1,35 @@ +export interface RngHolder { + rngState: number; +} + +/** mulberry32 — mutates holder.rngState, returns a float in [0, 1). */ +export function random(h: RngHolder): number { + let t = (h.rngState = (h.rngState + 0x6d2b79f5) | 0); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +export function randomInt(h: RngHolder, min: number, max: number): number { + return min + Math.floor(random(h) * (max - min + 1)); +} + +export function weightedPick( + h: RngHolder, + items: readonly T[], + weight: (item: T) => number, +): T | undefined { + const weights = items.map((it) => Math.max(0, weight(it))); + const total = weights.reduce((sum, w) => sum + w, 0); + if (total <= 0) return undefined; + let r = random(h) * total; + let lastPositive: T | undefined; + for (let i = 0; i < items.length; i++) { + const w = weights[i]; + if (w === 0) continue; + lastPositive = items[i]; + if (r < w) return items[i]; + r -= w; + } + return lastPositive; +} diff --git a/tests/sim/rng.test.ts b/tests/sim/rng.test.ts new file mode 100644 index 0000000..5ae2c96 --- /dev/null +++ b/tests/sim/rng.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { random, randomInt, weightedPick } from '../../src/sim/rng'; + +describe('rng', () => { + it('is deterministic for a given seed', () => { + const a = { rngState: 42 }; + const b = { rngState: 42 }; + const seqA = [random(a), random(a), random(a)]; + const seqB = [random(b), random(b), random(b)]; + expect(seqA).toEqual(seqB); + }); + + it('returns values in [0, 1)', () => { + const h = { rngState: 7 }; + for (let i = 0; i < 1000; i++) { + const v = random(h); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('randomInt stays within inclusive bounds', () => { + const h = { rngState: 1 }; + const seen = new Set(); + for (let i = 0; i < 1000; i++) { + const v = randomInt(h, 3, 5); + expect(v).toBeGreaterThanOrEqual(3); + expect(v).toBeLessThanOrEqual(5); + seen.add(v); + } + expect(seen.size).toBe(3); + }); + + it('weightedPick never picks zero-weight items', () => { + const h = { rngState: 3 }; + const items = ['a', 'b', 'c']; + for (let i = 0; i < 500; i++) { + const v = weightedPick(h, items, (x) => (x === 'b' ? 0 : 1)); + expect(v).not.toBe('b'); + } + }); + + it('weightedPick returns undefined when all weights are zero', () => { + const h = { rngState: 3 }; + expect(weightedPick(h, ['a'], () => 0)).toBeUndefined(); + expect(weightedPick(h, [], () => 1)).toBeUndefined(); + }); +});