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>
This commit is contained in:
75
pkg/sim/rng.go
Normal file
75
pkg/sim/rng.go
Normal file
@@ -0,0 +1,75 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user