mantimon-tcg/backend/app/core/rng.py
Cal Corum 3e82280efb Add game engine foundation: enums, config, and RNG modules
- Create core module structure with models and effects subdirectories
- Add enums module with CardType, EnergyType, TurnPhase, StatusCondition, etc.
- Add RulesConfig with Mantimon TCG defaults (40-card deck, 4 points to win)
- Add RandomProvider protocol with SeededRandom (testing) and SecureRandom (production)
- Include comprehensive tests for all modules (97 tests passing)

Defaults reflect GAME_RULES.md: Pokemon Pocket-style energy deck,
first turn can attack but not attach energy, 30-turn limit enabled.
2026-01-24 22:14:45 -06:00

242 lines
7.5 KiB
Python

"""Random number generation for the Mantimon TCG game engine.
This module provides a RandomProvider protocol with two implementations:
- SeededRandom: Deterministic RNG for testing and replays
- SecureRandom: Cryptographically secure RNG for production PvP
The RandomProvider abstraction allows the game engine to be tested with
predictable random outcomes while using secure randomness in production.
Usage:
# In tests - predictable outcomes
rng = SeededRandom(seed=42)
result = rng.coin_flip() # Always the same with same seed
# In production - secure randomness
rng = SecureRandom()
result = rng.coin_flip() # Unpredictable
# In game engine
class GameEngine:
def __init__(self, rng: RandomProvider | None = None):
self.rng = rng or SecureRandom()
"""
import random
import secrets
from collections.abc import MutableSequence, Sequence
from typing import Protocol, TypeVar
T = TypeVar("T")
class RandomProvider(Protocol):
"""Protocol for random number generation in the game engine.
This protocol defines the interface for all random operations needed
by the game engine. Implementations can be deterministic (for testing)
or cryptographically secure (for production).
All methods should be treated as if they have side effects (advancing
the RNG state), even if the underlying implementation is deterministic.
"""
def random(self) -> float:
"""Return a random float in the range [0.0, 1.0).
Returns:
A random float between 0.0 (inclusive) and 1.0 (exclusive).
"""
...
def randint(self, a: int, b: int) -> int:
"""Return a random integer N such that a <= N <= b.
Args:
a: Lower bound (inclusive).
b: Upper bound (inclusive).
Returns:
A random integer between a and b, inclusive.
"""
...
def choice(self, seq: Sequence[T]) -> T:
"""Return a random element from a non-empty sequence.
Args:
seq: A non-empty sequence to choose from.
Returns:
A randomly selected element from the sequence.
Raises:
IndexError: If the sequence is empty.
"""
...
def shuffle(self, seq: MutableSequence[T]) -> None:
"""Shuffle a mutable sequence in place.
Args:
seq: A mutable sequence to shuffle. Modified in place.
"""
...
def coin_flip(self) -> bool:
"""Simulate a fair coin flip.
Returns:
True for heads, False for tails.
"""
...
def sample(self, population: Sequence[T], k: int) -> list[T]:
"""Return k unique random elements from population.
Args:
population: Sequence to sample from.
k: Number of elements to select.
Returns:
A list of k unique elements from population.
Raises:
ValueError: If k is larger than the population.
"""
...
class SeededRandom:
"""Deterministic random number generator for testing and replays.
Uses Python's random.Random with a seed for reproducible sequences.
Given the same seed, the sequence of random values will always be identical.
Example:
rng1 = SeededRandom(seed=12345)
rng2 = SeededRandom(seed=12345)
# These will always be equal
assert rng1.randint(1, 100) == rng2.randint(1, 100)
assert rng1.coin_flip() == rng2.coin_flip()
"""
def __init__(self, seed: int | None = None) -> None:
"""Initialize with an optional seed.
Args:
seed: Integer seed for the RNG. If None, uses a random seed
(making this instance non-deterministic).
"""
self._rng = random.Random(seed)
self._seed = seed
@property
def seed(self) -> int | None:
"""The seed used to initialize this RNG, if any."""
return self._seed
def random(self) -> float:
"""Return a random float in the range [0.0, 1.0)."""
return self._rng.random()
def randint(self, a: int, b: int) -> int:
"""Return a random integer N such that a <= N <= b."""
return self._rng.randint(a, b)
def choice(self, seq: Sequence[T]) -> T:
"""Return a random element from a non-empty sequence."""
return self._rng.choice(seq)
def shuffle(self, seq: MutableSequence[T]) -> None:
"""Shuffle a mutable sequence in place."""
self._rng.shuffle(seq)
def coin_flip(self) -> bool:
"""Simulate a fair coin flip. True = heads, False = tails."""
return self._rng.random() < 0.5
def sample(self, population: Sequence[T], k: int) -> list[T]:
"""Return k unique random elements from population."""
return self._rng.sample(list(population), k)
class SecureRandom:
"""Cryptographically secure random number generator for production.
Uses Python's secrets module for unpredictable randomness. This should
be used for all PvP games where predictability would be a security issue.
Note: This implementation cannot be seeded and is not reproducible,
which is the intended behavior for production use.
"""
def random(self) -> float:
"""Return a random float in the range [0.0, 1.0)."""
# secrets.randbelow returns [0, n), so we divide to get [0.0, 1.0)
return secrets.randbelow(2**53) / (2**53)
def randint(self, a: int, b: int) -> int:
"""Return a random integer N such that a <= N <= b."""
if a > b:
raise ValueError(f"a ({a}) must be <= b ({b})")
return a + secrets.randbelow(b - a + 1)
def choice(self, seq: Sequence[T]) -> T:
"""Return a random element from a non-empty sequence."""
if not seq:
raise IndexError("Cannot choose from empty sequence")
return seq[secrets.randbelow(len(seq))]
def shuffle(self, seq: MutableSequence[T]) -> None:
"""Shuffle a mutable sequence in place using Fisher-Yates algorithm."""
n = len(seq)
for i in range(n - 1, 0, -1):
j = secrets.randbelow(i + 1)
seq[i], seq[j] = seq[j], seq[i]
def coin_flip(self) -> bool:
"""Simulate a fair coin flip. True = heads, False = tails."""
return secrets.randbelow(2) == 0
def sample(self, population: Sequence[T], k: int) -> list[T]:
"""Return k unique random elements from population."""
if k > len(population):
raise ValueError(f"Sample size {k} is larger than population size {len(population)}")
if k < 0:
raise ValueError(f"Sample size {k} must be non-negative")
# Use a set to track selected indices
pool = list(population)
result: list[T] = []
for _ in range(k):
idx = secrets.randbelow(len(pool))
result.append(pool.pop(idx))
return result
def create_rng(seed: int | None = None, secure: bool = False) -> RandomProvider:
"""Factory function to create an appropriate RandomProvider.
Args:
seed: If provided, creates a SeededRandom with this seed.
secure: If True and no seed provided, creates SecureRandom.
If False and no seed provided, creates unseeded SeededRandom.
Returns:
A RandomProvider instance.
Example:
# For testing with reproducible results
rng = create_rng(seed=42)
# For production PvP
rng = create_rng(secure=True)
"""
if seed is not None:
return SeededRandom(seed=seed)
if secure:
return SecureRandom()
return SeededRandom()