- 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.
242 lines
7.5 KiB
Python
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()
|