Files
casino/pkg/sim/rng.go
drjones 8fccbb2a9a feat(sim): add deterministic RNG and crash curve
Seed expansion uses SplitMix64 so all 32 seed bytes affect the stream;
copying the seed directly into xoshiro state left the first draw
dependent only on bytes 8-15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:48:17 +00:00

76 lines
2.2 KiB
Go

package sim
import (
"encoding/binary"
"github.com/drjones/quantum-arcade/pkg/fixed"
)
// RNG is a deterministic xoshiro256** generator seeded from 32 bytes.
// It uses only integer operations, so a browser replaying a round reproduces
// the server's stream exactly.
//
// This is the expansion function, not the entropy source: the seed itself comes
// from the commit-reveal protocol, which is what makes outcomes unriggable.
type RNG struct {
state [4]uint64
}
// NewRNG creates a reproducible generator from a 32-byte seed.
//
// The seed is run through SplitMix64 rather than copied into the state
// directly. Copying directly leaves the first output depending only on
// state[1], so seeds differing in other bytes produce identical first draws —
// which would make CrashPoint blind to most of its own seed.
func NewRNG(seed [32]byte) *RNG {
// Fold every seed byte into a single accumulator first, so all 32 bytes
// influence all four state words.
acc := uint64(0x9E3779B97F4A7C15)
for i := 0; i < 4; i++ {
acc ^= binary.LittleEndian.Uint64(seed[i*8 : i*8+8])
acc = splitMix64(&acc)
}
r := &RNG{}
for i := 0; i < 4; i++ {
r.state[i] = splitMix64(&acc)
}
// An all-zero state is a fixed point of the recurrence. SplitMix64 makes
// this vanishingly unlikely, but the guard costs nothing.
if r.state[0]|r.state[1]|r.state[2]|r.state[3] == 0 {
r.state[0] = 0x9E3779B97F4A7C15
}
return r
}
// splitMix64 advances x and returns a well-mixed 64-bit value. Every input bit
// affects every output bit, which is the property the state expansion needs.
func splitMix64(x *uint64) uint64 {
*x += 0x9E3779B97F4A7C15
z := *x
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
return z ^ (z >> 31)
}
// Uint64 returns the next 64 bits of the stream.
func (r *RNG) Uint64() uint64 {
s := &r.state
result := rotl(s[1]*5, 7) * 9
t := s[1] << 17
s[2] ^= s[0]
s[3] ^= s[1]
s[1] ^= s[2]
s[0] ^= s[3]
s[2] ^= t
s[3] = rotl(s[3], 45)
return result
}
func rotl(x uint64, k uint) uint64 { return (x << k) | (x >> (64 - k)) }
// Unit returns a fixed-point value uniformly distributed over [0, 1).
// Taking the top 32 bits places them exactly in the fractional field.
func (r *RNG) Unit() fixed.F {
return fixed.F(r.Uint64() >> 32)
}