feat(dice): seeded RNG protocol and dice notation parser

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 35068c4a65
commit 2a58826bd4
5 changed files with 512 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
"""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 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 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, 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 choice_index(self, _n: int) -> int:
if not self._queue:
raise RngExhaustedError(_EXHAUSTED_MSG)
return self._queue.popleft()