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
+196
View File
@@ -0,0 +1,196 @@
"""Tests for dice notation parsing and DiceExpr evaluation."""
from __future__ import annotations
from collections import Counter
import pytest
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice
from pf1e_simulator.rng import ScriptedRng
# ── Parsing: accepted forms ──────────────────────────────────────────────────
class TestParseDiceAccepted:
"""Given: valid dice notation strings
When: parse_dice is called
Then: returns a correct DiceExpr."""
def test_2d6_plus_3(self) -> None:
expr = parse_dice("2d6+3")
assert expr.count == 2
assert expr.sides == 6
assert expr.bonus == 3
def test_2d6_minus_1(self) -> None:
expr = parse_dice("2d6-1")
assert expr.count == 2
assert expr.sides == 6
assert expr.bonus == -1
def test_1d4_no_bonus(self) -> None:
expr = parse_dice("1d4")
assert expr.count == 1
assert expr.sides == 4
assert expr.bonus == 0
def test_d20_shorthand(self) -> None:
expr = parse_dice("d20")
assert expr.count == 1
assert expr.sides == 20
assert expr.bonus == 0
def test_flat_integer(self) -> None:
expr = parse_dice("7")
assert expr.count == 0
assert expr.sides == 0
assert expr.bonus == 7
def test_flat_negative(self) -> None:
expr = parse_dice("-5")
assert expr.count == 0
assert expr.sides == 0
assert expr.bonus == -5
def test_flat_zero(self) -> None:
expr = parse_dice("0")
assert expr.count == 0
assert expr.sides == 0
assert expr.bonus == 0
def test_large_dice(self) -> None:
expr = parse_dice("10d100+50")
assert expr.count == 10
assert expr.sides == 100
assert expr.bonus == 50
def test_whitespace_stripped(self) -> None:
expr = parse_dice(" 2d6+3 ")
assert expr.count == 2
assert expr.sides == 6
assert expr.bonus == 3
# ── Parsing: rejected forms ──────────────────────────────────────────────────
class TestParseDiceRejected:
"""Given: invalid dice notation strings
When: parse_dice is called
Then: raises DiceParseError carrying the offending text."""
@pytest.mark.parametrize(
"text",
[
"",
"sizeRoll(1, 8, @size)",
"2x6",
"1d",
"d",
"2d0",
"0d6",
"2d 6",
"abc",
"d6+",
],
)
def test_rejects_invalid(self, text: str) -> None:
with pytest.raises(DiceParseError) as exc_info:
parse_dice(text)
assert text in str(exc_info.value)
# ── Mean calculation ─────────────────────────────────────────────────────────
class TestDiceExprMean:
"""Given: a DiceExpr
When: mean() is called
Then: returns count * (sides + 1) / 2 + bonus."""
def test_2d6_plus_3_mean(self) -> None:
assert DiceExpr(count=2, sides=6, bonus=3).mean() == 10.0
def test_1d4_mean(self) -> None:
assert DiceExpr(count=1, sides=4, bonus=0).mean() == 2.5
def test_d20_mean(self) -> None:
assert DiceExpr(count=1, sides=20, bonus=0).mean() == 10.5
def test_flat_bonus_mean(self) -> None:
assert DiceExpr(count=0, sides=0, bonus=7).mean() == 7.0
def test_negative_bonus_mean(self) -> None:
assert DiceExpr(count=1, sides=6, bonus=-2).mean() == 1.5
# ── Roll: uses RNG correctly ────────────────────────────────────────────────
class TestDiceExprRoll:
"""Given: a DiceExpr and a ScriptedRng
When: roll(rng) is called
Then: consumes exactly `count` values from the RNG."""
def test_2d6_plus_3_uses_two_rolls(self) -> None:
rng = ScriptedRng([3, 4])
expr = DiceExpr(count=2, sides=6, bonus=3)
assert expr.roll(rng) == 3 + 4 + 3 # 10
def test_1d20_uses_one_roll(self) -> None:
rng = ScriptedRng([15])
expr = DiceExpr(count=1, sides=20, bonus=0)
assert expr.roll(rng) == 15
def test_flat_bonus_uses_no_rolls(self) -> None:
rng = ScriptedRng([])
expr = DiceExpr(count=0, sides=0, bonus=7)
assert expr.roll(rng) == 7
def test_negative_bonus(self) -> None:
rng = ScriptedRng([6, 6])
expr = DiceExpr(count=2, sides=6, bonus=-1)
assert expr.roll(rng) == 6 + 6 - 1 # 11
# ── Distribution: exhaustive 2d6+3 ──────────────────────────────────────────
class TestDiceExprDistribution:
"""Given: 2d6+3 enumerated over all 36 face pairs
When: probabilities are computed
Then: they match the theoretical distribution."""
def test_2d6_plus_3_distribution(self) -> None:
totals: Counter[int] = Counter()
for d1 in range(1, 7):
for d2 in range(1, 7):
rng = ScriptedRng([d1, d2])
expr = DiceExpr(count=2, sides=6, bonus=3)
totals[expr.roll(rng)] += 1
assert totals[5] == 1 # (1,1) → 1+1+3=5
assert totals[7] == 3 # (1,3),(2,2),(3,1) → sum=4+3=7
assert totals[10] == 6 # sum=7: (1,6)..(6,1) → 7+3=10
assert totals[15] == 1 # (6,6) → 12+3=15
assert sum(totals.values()) == 36
# ── DiceExpr.flat factory ────────────────────────────────────────────────────
class TestDiceExprFlat:
"""Given: DiceExpr.flat(n)
When: called with any integer
Then: returns count=0, sides=0, bonus=n."""
def test_flat_positive(self) -> None:
expr = DiceExpr.flat(10)
assert expr.count == 0
assert expr.sides == 0
assert expr.bonus == 10
assert expr.mean() == 10.0
def test_flat_negative(self) -> None:
expr = DiceExpr.flat(-3)
assert expr.bonus == -3
def test_flat_roll_returns_bonus(self) -> None:
rng = ScriptedRng([])
expr = DiceExpr.flat(5)
assert expr.roll(rng) == 5
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the RNG protocol, SeededRng, and ScriptedRng."""
import pytest
from pf1e_simulator.rng import RngExhaustedError, ScriptedRng, SeededRng
class TestSeededRngRollDie:
"""Given: a SeededRng with a fixed seed
When: roll_die is called
Then: returns values in 1..=sides deterministically."""
def test_roll_die_returns_in_range(self) -> None:
rng = SeededRng(42)
for _ in range(100):
result = rng.roll_die(6)
assert 1 <= result <= 6
def test_roll_die_d20_in_range(self) -> None:
rng = SeededRng(42)
for _ in range(100):
result = rng.d20()
assert 1 <= result <= 20
def test_d20_is_roll_die_20(self) -> None:
rng1 = SeededRng(123)
rng2 = SeededRng(123)
assert rng1.d20() == rng2.roll_die(20)
class TestSeededRngChoiceIndex:
"""Given: a SeededRng
When: choice_index(n) is called
Then: returns values in 0..n-1."""
def test_choice_index_in_range(self) -> None:
rng = SeededRng(42)
for _ in range(100):
result = rng.choice_index(5)
assert 0 <= result <= 4
class TestSeededRngReproducibility:
"""Given: two SeededRng instances with the same seed
When: each produces 1000 rolls
Then: the streams are identical."""
def test_identical_d20_streams(self) -> None:
rng1 = SeededRng(42)
rng2 = SeededRng(42)
rolls1 = [rng1.d20() for _ in range(1000)]
rolls2 = [rng2.d20() for _ in range(1000)]
assert rolls1 == rolls2
def test_identical_choice_index_streams(self) -> None:
rng1 = SeededRng(42)
rng2 = SeededRng(42)
idx1 = [rng1.choice_index(8) for _ in range(1000)]
idx2 = [rng2.choice_index(8) for _ in range(1000)]
assert idx1 == idx2
def test_different_seeds_produce_different_streams(self) -> None:
rng1 = SeededRng(1)
rng2 = SeededRng(2)
rolls1 = [rng1.d20() for _ in range(100)]
rolls2 = [rng2.d20() for _ in range(100)]
assert rolls1 != rolls2
class TestScriptedRng:
"""Given: a ScriptedRng with a queue of ints
When: roll_die / choice_index is called
Then: returns queued values in order."""
def test_roll_die_returns_queued_values(self) -> None:
rng = ScriptedRng([3, 1, 4, 1, 5])
assert rng.roll_die(6) == 3
assert rng.roll_die(6) == 1
assert rng.roll_die(6) == 4
def test_d20_returns_queued_values(self) -> None:
rng = ScriptedRng([20, 1, 13])
assert rng.d20() == 20
assert rng.d20() == 1
assert rng.d20() == 13
def test_choice_index_returns_queued_values(self) -> None:
rng = ScriptedRng([0, 2, 1])
assert rng.choice_index(5) == 0
assert rng.choice_index(5) == 2
assert rng.choice_index(5) == 1
def test_raises_on_exhaustion_roll_die(self) -> None:
rng = ScriptedRng([5])
rng.roll_die(6)
with pytest.raises(RngExhaustedError):
rng.roll_die(6)
def test_raises_on_exhaustion_d20(self) -> None:
rng = ScriptedRng([10])
rng.d20()
with pytest.raises(RngExhaustedError):
rng.d20()
def test_raises_on_exhaustion_choice_index(self) -> None:
rng = ScriptedRng([0])
rng.choice_index(3)
with pytest.raises(RngExhaustedError):
rng.choice_index(3)
def test_exhaustion_error_carries_message(self) -> None:
rng = ScriptedRng([1])
rng.roll_die(6)
with pytest.raises(RngExhaustedError, match="exhausted"):
rng.roll_die(6)