88 lines
2.3 KiB
Python
88 lines
2.3 KiB
Python
"""Seeded and scripted RNG protocol for deterministic Monte Carlo simulation.
|
|
|
|
Design: every die roll in the engine flows through the `Rng` protocol.
|
|
`SeededRng` wraps `random.Random(seed)` for reproducible streams.
|
|
`ScriptedRng` pops from a fixed queue — used in tests to script exact outcomes.
|
|
|
|
Security note: `random.Random` is NOT cryptographically secure.
|
|
This is by design — the simulator needs reproducibility, not security.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from collections import deque
|
|
from typing import Protocol
|
|
|
|
_EXHAUSTED_MSG = "ScriptedRng queue exhausted"
|
|
|
|
|
|
class RngExhaustedError(Exception):
|
|
"""Raised when a ScriptedRng queue is exhausted."""
|
|
|
|
|
|
class Rng(Protocol):
|
|
"""Protocol for deterministic die-roll sources."""
|
|
|
|
def roll_die(self, sides: int) -> int:
|
|
"""Return a uniform integer in 1..=sides."""
|
|
...
|
|
|
|
def d20(self) -> int:
|
|
"""Return roll_die(20)."""
|
|
...
|
|
|
|
def d100(self) -> int:
|
|
"""Return roll_die(100)."""
|
|
...
|
|
|
|
def choice_index(self, n: int) -> int:
|
|
"""Return a uniform integer in 0..n-1 for deterministic tie-breaks."""
|
|
...
|
|
|
|
|
|
class SeededRng:
|
|
"""Rng backed by random.Random(seed) for reproducible streams."""
|
|
|
|
def __init__(self, seed: int) -> None:
|
|
self._rng = random.Random(seed)
|
|
|
|
def roll_die(self, sides: int) -> int:
|
|
return self._rng.randint(1, sides)
|
|
|
|
def d20(self) -> int:
|
|
return self.roll_die(20)
|
|
|
|
def d100(self) -> int:
|
|
return self.roll_die(100)
|
|
|
|
def choice_index(self, n: int) -> int:
|
|
return self._rng.randrange(n)
|
|
|
|
|
|
class ScriptedRng:
|
|
"""Rng that pops predetermined values from a queue.
|
|
|
|
Each call to roll_die, d20, d100, or choice_index consumes one value.
|
|
Raises RngExhaustedError when the queue is empty.
|
|
"""
|
|
|
|
def __init__(self, queue: list[int]) -> None:
|
|
self._queue: deque[int] = deque(queue)
|
|
|
|
def roll_die(self, _sides: int) -> int:
|
|
if not self._queue:
|
|
raise RngExhaustedError(_EXHAUSTED_MSG)
|
|
return self._queue.popleft()
|
|
|
|
def d20(self) -> int:
|
|
return self.roll_die(20)
|
|
|
|
def d100(self) -> int:
|
|
return self.roll_die(100)
|
|
|
|
def choice_index(self, _n: int) -> int:
|
|
if not self._queue:
|
|
raise RngExhaustedError(_EXHAUSTED_MSG)
|
|
return self._queue.popleft()
|