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.
This commit is contained in:
Cal Corum 2026-01-24 22:14:45 -06:00
parent 2cb99e9676
commit 3e82280efb
13 changed files with 2760 additions and 0 deletions

652
backend/PROJECT_PLAN.json Normal file
View File

@ -0,0 +1,652 @@
{
"meta": {
"version": "1.0.0",
"created": "2026-01-24",
"lastUpdated": "2026-01-24",
"planType": "feature",
"projectName": "Mantimon TCG - Backend Game Engine",
"description": "Core game engine scaffolding for a highly configurable Pokemon TCG-inspired card game. The engine must support campaign mode with fixed rules and free play mode with user-configurable rules.",
"totalEstimatedHours": 48,
"totalTasks": 32,
"completedTasks": 0
},
"categories": {
"critical": "Foundation components that block all other work",
"high": "Core engine functionality required for basic gameplay",
"medium": "Important features for complete gameplay experience",
"low": "Polish, optimization, and nice-to-have features",
"feature": "New capabilities beyond MVP"
},
"architectureDecisions": {
"configurability": "All game rules driven by RulesConfig - defaults for campaign, user-adjustable for free play",
"rngHandling": "RandomProvider protocol with SeededRandom (tests/replays) and SecureRandom (production PvP)",
"cardRegistry": "Hybrid - definitions loaded from DB via CardService, embedded in GameState at game creation for self-contained gameplay",
"cardModels": "Separate CardDefinition (immutable template) from CardInstance (mutable in-game state)",
"actionModeling": "Union types for type safety",
"asyncSupport": "Async throughout for WebSocket compatibility"
},
"directoryStructure": {
"core": "backend/app/core/",
"models": "backend/app/core/models/",
"effects": "backend/app/core/effects/",
"tests": "backend/tests/core/"
},
"tasks": [
{
"id": "CRIT-001",
"name": "Create core module structure",
"description": "Set up the directory structure and __init__.py files for the core game engine module hierarchy",
"category": "critical",
"priority": 1,
"completed": false,
"tested": false,
"dependencies": [],
"files": [
{"path": "app/core/__init__.py", "issue": "File does not exist"},
{"path": "app/core/models/__init__.py", "issue": "File does not exist"},
{"path": "app/core/effects/__init__.py", "issue": "File does not exist"},
{"path": "tests/core/__init__.py", "issue": "File does not exist"},
{"path": "tests/core/test_models/__init__.py", "issue": "File does not exist"},
{"path": "tests/core/test_effects/__init__.py", "issue": "File does not exist"}
],
"suggestedFix": "Create all directories and empty __init__.py files with appropriate module docstrings",
"estimatedHours": 0.5,
"notes": "Foundation task - must be completed first"
},
{
"id": "CRIT-002",
"name": "Create enums module",
"description": "Define all enumeration types used throughout the game engine: CardType, EnergyType, PokemonStage, TurnPhase, StatusCondition, TrainerType, ActionType",
"category": "critical",
"priority": 2,
"completed": false,
"tested": false,
"dependencies": ["CRIT-001"],
"files": [
{"path": "app/core/models/enums.py", "issue": "File does not exist"}
],
"suggestedFix": "Create StrEnum classes for each enumeration type. Use StrEnum for JSON serialization compatibility.",
"estimatedHours": 1,
"notes": "All other modules depend on these enums. Consider future extensibility for custom energy types."
},
{
"id": "TEST-001",
"name": "Create enums tests",
"description": "Test that all enums serialize correctly to JSON, have expected values, and can be used in Pydantic models",
"category": "high",
"priority": 3,
"completed": false,
"tested": false,
"dependencies": ["CRIT-002"],
"files": [
{"path": "tests/core/test_models/test_enums.py", "issue": "File does not exist"}
],
"suggestedFix": "Test each enum: value consistency, JSON round-trip, membership checks",
"estimatedHours": 0.5,
"notes": "Simple tests but establish testing patterns for the project"
},
{
"id": "CRIT-003",
"name": "Create RulesConfig module",
"description": "Define the master configuration system for all game rules with sensible defaults. Includes: DeckConfig, BenchConfig, EnergyConfig, PrizeConfig, FirstTurnConfig, WinConditionsConfig, StatusConfig, TrainerConfig",
"category": "critical",
"priority": 4,
"completed": false,
"tested": false,
"dependencies": ["CRIT-002"],
"files": [
{"path": "app/core/config.py", "issue": "File does not exist"}
],
"suggestedFix": "Create nested Pydantic BaseModel classes with Field defaults. Use Field(default_factory=...) for mutable defaults.",
"estimatedHours": 2,
"notes": "This is the heart of the configurability requirement. Defaults should approximate standard Pokemon TCG rules. Document each config option thoroughly."
},
{
"id": "TEST-002",
"name": "Create RulesConfig tests",
"description": "Test that RulesConfig has sensible defaults, custom values override correctly, and serialization round-trips work",
"category": "high",
"priority": 5,
"completed": false,
"tested": false,
"dependencies": ["CRIT-003"],
"files": [
{"path": "tests/core/test_config.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: default instantiation, partial overrides, full JSON round-trip, validation of invalid values",
"estimatedHours": 1,
"notes": "Important to verify all defaults match expected standard rules"
},
{
"id": "CRIT-004",
"name": "Create RandomProvider module",
"description": "Implement the RandomProvider protocol with SeededRandom (for testing/replays) and SecureRandom (for production PvP) implementations",
"category": "critical",
"priority": 6,
"completed": false,
"tested": false,
"dependencies": ["CRIT-001"],
"files": [
{"path": "app/core/rng.py", "issue": "File does not exist"}
],
"suggestedFix": "Create Protocol class with random(), randint(), choice(), shuffle() methods. Implement SeededRandom using random.Random with seed, SecureRandom using secrets module.",
"estimatedHours": 1.5,
"notes": "Critical for testability. SeededRandom enables deterministic tests for coin flips and shuffles."
},
{
"id": "TEST-003",
"name": "Create RandomProvider tests",
"description": "Test that SeededRandom produces deterministic results and SecureRandom produces varied results",
"category": "high",
"priority": 7,
"completed": false,
"tested": false,
"dependencies": ["CRIT-004"],
"files": [
{"path": "tests/core/test_rng.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: SeededRandom with same seed produces identical sequences, SecureRandom produces different values across calls (statistical test)",
"estimatedHours": 1,
"notes": "SeededRandom tests should be fully deterministic. SecureRandom tests should verify randomness."
},
{
"id": "HIGH-001",
"name": "Create CardDefinition and CardInstance models",
"description": "Define the card template (CardDefinition) and in-game card state (CardInstance) models. Includes Attack, Ability sub-models.",
"category": "high",
"priority": 8,
"completed": false,
"tested": false,
"dependencies": ["CRIT-002"],
"files": [
{"path": "app/core/models/card.py", "issue": "File does not exist"}
],
"suggestedFix": "CardDefinition: immutable template with id, name, card_type, stage, hp, attacks, abilities, weakness, resistance, retreat_cost. CardInstance: mutable state with instance_id, definition_id, damage, attached_energy, status_conditions, turn_played.",
"estimatedHours": 2,
"notes": "CardInstance should only store mutable state. All static card data comes from CardDefinition lookup."
},
{
"id": "TEST-004",
"name": "Create card model tests",
"description": "Test CardDefinition and CardInstance creation, validation, and serialization",
"category": "high",
"priority": 9,
"completed": false,
"tested": false,
"dependencies": ["HIGH-001"],
"files": [
{"path": "tests/core/test_models/test_card.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: Pokemon card creation with attacks, Trainer card creation, Energy card creation, CardInstance damage tracking, energy attachment, status conditions",
"estimatedHours": 1.5,
"notes": "Create sample card fixtures for reuse in other tests"
},
{
"id": "HIGH-002",
"name": "Create Action union types",
"description": "Define all player action types as Pydantic models with Literal type discriminators: PlayPokemonAction, EvolvePokemonAction, AttachEnergyAction, PlayTrainerAction, UseAbilityAction, AttackAction, RetreatAction, PassAction",
"category": "high",
"priority": 10,
"completed": false,
"tested": false,
"dependencies": ["CRIT-002"],
"files": [
{"path": "app/core/models/actions.py", "issue": "File does not exist"}
],
"suggestedFix": "Each action model has a 'type' field with Literal value for discrimination. Create Action = Union[...] type alias for all actions.",
"estimatedHours": 1.5,
"notes": "Union type enables type-safe action handling. Each action should have all parameters needed for validation and execution."
},
{
"id": "TEST-005",
"name": "Create action model tests",
"description": "Test that action union types discriminate correctly and serialize/deserialize properly",
"category": "high",
"priority": 11,
"completed": false,
"tested": false,
"dependencies": ["HIGH-002"],
"files": [
{"path": "tests/core/test_models/test_actions.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: each action type creates correctly, JSON round-trip works, discriminated union parses correct type",
"estimatedHours": 1,
"notes": "Test that parsing JSON into Action union correctly identifies the specific action type"
},
{
"id": "HIGH-003",
"name": "Create GameState, PlayerState, and Zone models",
"description": "Define the complete game state model hierarchy: Zone (card collection with operations), PlayerState (all player zones and turn state), GameState (full game including card_registry, rules, players, turn tracking)",
"category": "high",
"priority": 12,
"completed": false,
"tested": false,
"dependencies": ["CRIT-003", "HIGH-001"],
"files": [
{"path": "app/core/models/game_state.py", "issue": "File does not exist"}
],
"suggestedFix": "Zone: list of CardInstance with add/remove/shuffle/draw methods. PlayerState: deck, hand, active, bench, prizes, discard zones plus turn state flags. GameState: game_id, rules, card_registry, players dict, turn tracking, winner.",
"estimatedHours": 3,
"notes": "GameState.card_registry holds all CardDefinitions used in this game. Zone operations need RandomProvider for shuffle."
},
{
"id": "TEST-006",
"name": "Create game state model tests",
"description": "Test Zone operations, PlayerState initialization and turn resets, GameState properties and player access",
"category": "high",
"priority": 13,
"completed": false,
"tested": false,
"dependencies": ["HIGH-003"],
"files": [
{"path": "tests/core/test_models/test_game_state.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: Zone add/remove/shuffle/draw, PlayerState turn state resets, GameState current_player property, GameState is_first_turn logic",
"estimatedHours": 2,
"notes": "Use SeededRandom for deterministic shuffle tests"
},
{
"id": "HIGH-004",
"name": "Create test fixtures (conftest.py)",
"description": "Create shared pytest fixtures for sample cards, game states, and seeded RNG instances",
"category": "high",
"priority": 14,
"completed": false,
"tested": false,
"dependencies": ["HIGH-001", "HIGH-003", "CRIT-004"],
"files": [
{"path": "tests/core/conftest.py", "issue": "File does not exist"}
],
"suggestedFix": "Create fixtures: sample_pokemon_card, sample_trainer_card, sample_energy_card, sample_deck, empty_game_state, mid_game_state, seeded_rng",
"estimatedHours": 1.5,
"notes": "Fixtures should be composable and reusable across all test modules"
},
{
"id": "MED-001",
"name": "Create EffectContext and base types",
"description": "Define the context object passed to effect handlers and the EffectResult return type",
"category": "medium",
"priority": 15,
"completed": false,
"tested": false,
"dependencies": ["HIGH-003"],
"files": [
{"path": "app/core/effects/base.py", "issue": "File does not exist"}
],
"suggestedFix": "EffectContext: game state, source player/card, target player/card, params dict, rng provider. EffectResult: success bool, message, state changes list for logging.",
"estimatedHours": 1,
"notes": "EffectContext should provide helper methods for common operations (get card by id, get opponent, etc.)"
},
{
"id": "MED-002",
"name": "Create effect handler registry",
"description": "Implement the effect handler registry with decorator for registering handlers and lookup function for resolving effects",
"category": "medium",
"priority": 16,
"completed": false,
"tested": false,
"dependencies": ["MED-001"],
"files": [
{"path": "app/core/effects/registry.py", "issue": "File does not exist"}
],
"suggestedFix": "Global EFFECT_REGISTRY dict. @effect_handler(name) decorator adds function to registry. resolve_effect(effect_id, context) looks up and calls handler.",
"estimatedHours": 1,
"notes": "Consider async handlers for effects that might need I/O in the future"
},
{
"id": "TEST-007",
"name": "Create effect registry tests",
"description": "Test that effect handlers register correctly and resolve_effect calls the right handler",
"category": "medium",
"priority": 17,
"completed": false,
"tested": false,
"dependencies": ["MED-002"],
"files": [
{"path": "tests/core/test_effects/test_registry.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: decorator registers handler, resolve_effect calls correct handler, unknown effect_id handled gracefully",
"estimatedHours": 1,
"notes": "Use mock handlers for testing registration"
},
{
"id": "MED-003",
"name": "Create built-in effect handlers",
"description": "Implement common effect handlers: deal_damage, heal, draw_cards, discard_cards, apply_status, remove_status, coin_flip, discard_energy, search_deck",
"category": "medium",
"priority": 18,
"completed": false,
"tested": false,
"dependencies": ["MED-002"],
"files": [
{"path": "app/core/effects/handlers.py", "issue": "File does not exist"}
],
"suggestedFix": "Each handler is an async function decorated with @effect_handler. Use context.params for effect-specific parameters. Return EffectResult with success/failure.",
"estimatedHours": 3,
"notes": "coin_flip handler should use context.rng for testability. Effects should mutate game state in place."
},
{
"id": "TEST-008",
"name": "Create effect handler tests",
"description": "Test each built-in effect handler with various scenarios",
"category": "medium",
"priority": 19,
"completed": false,
"tested": false,
"dependencies": ["MED-003", "HIGH-004"],
"files": [
{"path": "tests/core/test_effects/test_handlers.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: deal_damage reduces HP, heal restores HP up to max, draw_cards moves from deck to hand, apply_status adds condition, coin_flip with seeded RNG",
"estimatedHours": 2,
"notes": "Use seeded RNG fixtures for deterministic coin flip tests"
},
{
"id": "HIGH-005",
"name": "Create rules validator",
"description": "Implement config-driven action validation: check turn, phase, card ownership, action legality based on RulesConfig",
"category": "high",
"priority": 20,
"completed": false,
"tested": false,
"dependencies": ["HIGH-002", "HIGH-003", "CRIT-003"],
"files": [
{"path": "app/core/rules_validator.py", "issue": "File does not exist"}
],
"suggestedFix": "ValidationResult model with valid bool and reason string. validate_action(game, player_id, action) checks: is it player's turn, is phase correct, does player have the card, is action legal per rules. Separate validator functions per action type.",
"estimatedHours": 4,
"notes": "Most complex validation module. Must check all rule configurations (energy attachments per turn, supporter limit, bench size, etc.)"
},
{
"id": "TEST-009",
"name": "Create rules validator tests",
"description": "Test action validation for each action type with valid and invalid scenarios",
"category": "high",
"priority": 21,
"completed": false,
"tested": false,
"dependencies": ["HIGH-005", "HIGH-004"],
"files": [
{"path": "tests/core/test_rules_validator.py", "issue": "File does not exist"}
],
"suggestedFix": "Test per action type: valid action passes, wrong turn fails, wrong phase fails, card not owned fails, rule limit exceeded fails. Test with custom RulesConfig to verify config-driven behavior.",
"estimatedHours": 3,
"notes": "Critical tests - security depends on proper validation"
},
{
"id": "HIGH-006",
"name": "Create win conditions checker",
"description": "Implement config-driven win condition checking: all prizes taken, no Pokemon in play, cannot draw",
"category": "high",
"priority": 22,
"completed": false,
"tested": false,
"dependencies": ["HIGH-003", "CRIT-003"],
"files": [
{"path": "app/core/win_conditions.py", "issue": "File does not exist"}
],
"suggestedFix": "WinResult model with winner player_id and reason string. check_win_conditions(game) checks each enabled condition from rules config and returns WinResult if any are met.",
"estimatedHours": 1.5,
"notes": "Check each condition independently based on game.rules.win_conditions flags"
},
{
"id": "TEST-010",
"name": "Create win conditions tests",
"description": "Test each win condition triggers correctly and respects config flags",
"category": "high",
"priority": 23,
"completed": false,
"tested": false,
"dependencies": ["HIGH-006", "HIGH-004"],
"files": [
{"path": "tests/core/test_win_conditions.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: all prizes taken triggers win, last Pokemon knocked out triggers win, empty deck triggers win, disabled conditions don't trigger, custom prize count works",
"estimatedHours": 1.5,
"notes": "Test with different RulesConfig to verify each condition can be disabled"
},
{
"id": "HIGH-007",
"name": "Create turn manager",
"description": "Implement the turn/phase state machine with valid transitions and turn start/end handling",
"category": "high",
"priority": 24,
"completed": false,
"tested": false,
"dependencies": ["HIGH-003", "CRIT-002"],
"files": [
{"path": "app/core/turn_manager.py", "issue": "File does not exist"}
],
"suggestedFix": "TurnManager class with advance_phase(), end_turn(), start_turn() methods. Enforce valid transitions (DRAW->MAIN->ATTACK->END). Handle between-turn effects (poison/burn damage). Reset per-turn flags on turn start.",
"estimatedHours": 2.5,
"notes": "State machine should be strict about valid transitions. Consider setup phase for game initialization."
},
{
"id": "TEST-011",
"name": "Create turn manager tests",
"description": "Test phase transitions, turn switching, and per-turn state resets",
"category": "high",
"priority": 25,
"completed": false,
"tested": false,
"dependencies": ["HIGH-007", "HIGH-004"],
"files": [
{"path": "tests/core/test_turn_manager.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: valid transitions work, invalid transitions rejected, turn switch alternates players, per-turn flags reset, between-turn effects applied",
"estimatedHours": 2,
"notes": "Test poison/burn damage application between turns"
},
{
"id": "HIGH-008",
"name": "Create visibility filter",
"description": "Implement hidden information filtering to create client-safe game state views",
"category": "high",
"priority": 26,
"completed": false,
"tested": false,
"dependencies": ["HIGH-003"],
"files": [
{"path": "app/core/visibility.py", "issue": "File does not exist"}
],
"suggestedFix": "VisibleGameState model with filtered data. get_visible_state(game, player_id) returns: full own hand, own prize count, opponent hand COUNT only, opponent deck COUNT only, public battlefield/discard. Never expose deck order or opponent hand contents.",
"estimatedHours": 2,
"notes": "CRITICAL SECURITY: This prevents cheating. Must never leak hidden information."
},
{
"id": "TEST-012",
"name": "Create visibility filter tests",
"description": "Test that hidden information is never leaked and public information is preserved",
"category": "high",
"priority": 27,
"completed": false,
"tested": false,
"dependencies": ["HIGH-008", "HIGH-004"],
"files": [
{"path": "tests/core/test_visibility.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: opponent hand contents not visible, opponent deck order not visible, own hand fully visible, battlefield fully visible, prize counts visible but not contents",
"estimatedHours": 1.5,
"notes": "Security-critical tests. Verify no hidden data leaks through any path."
},
{
"id": "HIGH-009",
"name": "Create main GameEngine orchestrator",
"description": "Implement the main GameEngine class that orchestrates all components: game creation, action validation, action execution, win condition checking",
"category": "high",
"priority": 28,
"completed": false,
"tested": false,
"dependencies": ["HIGH-005", "HIGH-006", "HIGH-007", "HIGH-008", "MED-002"],
"files": [
{"path": "app/core/engine.py", "issue": "File does not exist"}
],
"suggestedFix": "GameEngine class with: __init__(rules, rng_provider), create_game(player_ids, decks, card_registry), validate_action(game, player_id, action), execute_action(game, player_id, action), check_win_conditions(game), get_visible_state(game, player_id). All methods async.",
"estimatedHours": 4,
"notes": "This is the main public API. Should be the only entry point for game operations."
},
{
"id": "TEST-013",
"name": "Create GameEngine integration tests",
"description": "Test full game flow from creation through actions to win condition",
"category": "high",
"priority": 29,
"completed": false,
"tested": false,
"dependencies": ["HIGH-009", "HIGH-004"],
"files": [
{"path": "tests/core/test_engine.py", "issue": "File does not exist"}
],
"suggestedFix": "Test: create game with two players, execute valid actions, reject invalid actions, detect win condition, full sample game playthrough",
"estimatedHours": 3,
"notes": "Integration tests should cover realistic game scenarios"
},
{
"id": "MED-004",
"name": "Create core module exports",
"description": "Set up clean public API exports in __init__.py files",
"category": "medium",
"priority": 30,
"completed": false,
"tested": false,
"dependencies": ["HIGH-009"],
"files": [
{"path": "app/core/__init__.py", "issue": "Needs public API exports"},
{"path": "app/core/models/__init__.py", "issue": "Needs model exports"},
{"path": "app/core/effects/__init__.py", "issue": "Needs effect exports"}
],
"suggestedFix": "Export key classes: GameEngine, RulesConfig, GameState, CardDefinition, CardInstance, Action types, effect_handler decorator",
"estimatedHours": 0.5,
"notes": "Clean API surface for consumers of the core module"
},
{
"id": "DOCS-001",
"name": "Create core module CLAUDE.md",
"description": "Document the game engine architecture, patterns, and usage guidelines for AI agents",
"category": "low",
"priority": 31,
"completed": false,
"tested": false,
"dependencies": ["HIGH-009"],
"files": [
{"path": "app/core/CLAUDE.md", "issue": "File does not exist"}
],
"suggestedFix": "Document: module structure, key classes, effect handler pattern, configuration system, testing approach, security considerations (hidden info)",
"estimatedHours": 1,
"notes": "Reference doc as specified in CLAUDE.md"
},
{
"id": "LOW-001",
"name": "Add comprehensive docstrings",
"description": "Ensure all public classes and methods have detailed docstrings with examples",
"category": "low",
"priority": 32,
"completed": false,
"tested": false,
"dependencies": ["HIGH-009"],
"files": [
{"path": "app/core/", "issue": "Review all files for docstring coverage"}
],
"suggestedFix": "Add Google-style docstrings with Args, Returns, Raises, and Example sections",
"estimatedHours": 2,
"notes": "Good documentation enables better AI assistance and maintainability"
}
],
"quickWins": [
{
"taskId": "CRIT-001",
"estimatedMinutes": 30,
"impact": "Unblocks all other development"
},
{
"taskId": "CRIT-002",
"estimatedMinutes": 60,
"impact": "Foundation for all models"
},
{
"taskId": "MED-004",
"estimatedMinutes": 30,
"impact": "Clean public API"
}
],
"productionBlockers": [
{
"taskId": "HIGH-005",
"reason": "Cannot trust client actions without server-side validation"
},
{
"taskId": "HIGH-008",
"reason": "Hidden information must never leak to clients"
},
{
"taskId": "HIGH-009",
"reason": "No gameplay possible without engine orchestrator"
}
],
"weeklyRoadmap": {
"week1": {
"theme": "Foundation & Models",
"tasks": ["CRIT-001", "CRIT-002", "TEST-001", "CRIT-003", "TEST-002", "CRIT-004", "TEST-003"],
"estimatedHours": 8,
"goals": ["Module structure complete", "Enums, config, and RNG working with tests"]
},
"week2": {
"theme": "Core Models",
"tasks": ["HIGH-001", "TEST-004", "HIGH-002", "TEST-005", "HIGH-003", "TEST-006", "HIGH-004"],
"estimatedHours": 12,
"goals": ["All data models complete", "Test fixtures established"]
},
"week3": {
"theme": "Effects System",
"tasks": ["MED-001", "MED-002", "TEST-007", "MED-003", "TEST-008"],
"estimatedHours": 8,
"goals": ["Effect handler system working", "Built-in effects implemented"]
},
"week4": {
"theme": "Game Logic",
"tasks": ["HIGH-005", "TEST-009", "HIGH-006", "TEST-010", "HIGH-007", "TEST-011"],
"estimatedHours": 14,
"goals": ["Validation working", "Win conditions working", "Turn management working"]
},
"week5": {
"theme": "Engine & Polish",
"tasks": ["HIGH-008", "TEST-012", "HIGH-009", "TEST-013", "MED-004", "DOCS-001", "LOW-001"],
"estimatedHours": 14,
"goals": ["GameEngine complete", "Full integration tested", "Documentation complete"]
}
},
"testingStrategy": {
"unitTests": "Each module has corresponding test file",
"integrationTests": "test_engine.py covers full game flow",
"fixtures": "conftest.py provides reusable sample data",
"determinism": "SeededRandom enables reproducible random tests",
"coverage": "Target 90%+ coverage on core module"
},
"securityChecklist": [
{
"item": "Deck order never sent to client",
"module": "visibility.py",
"verified": false
},
{
"item": "Opponent hand contents never sent",
"module": "visibility.py",
"verified": false
},
{
"item": "Prize card contents hidden until taken",
"module": "visibility.py",
"verified": false
},
{
"item": "All actions validated server-side",
"module": "rules_validator.py",
"verified": false
},
{
"item": "RNG unpredictable in production",
"module": "rng.py",
"verified": false
}
]
}

View File

@ -0,0 +1,17 @@
"""Mantimon TCG - Core Game Engine.
This module contains the core game engine for Mantimon TCG, a highly configurable
Pokemon TCG-inspired card game. The engine supports both campaign mode with fixed
rules and free play mode with user-configurable rules.
Key Components:
- config: RulesConfig and sub-configs for all game rules
- models: Data models for cards, game state, and actions
- effects: Effect handler system for card abilities and attacks
- engine: Main GameEngine orchestrator
- turn_manager: Turn/phase state machine
- rules_validator: Action legality checking
- win_conditions: Win/loss detection
- visibility: Hidden information filtering for clients
- rng: Random number generation with testable seeded implementation
"""

305
backend/app/core/config.py Normal file
View File

@ -0,0 +1,305 @@
"""Game rules configuration for Mantimon TCG.
This module defines the master configuration system for all game rules. The engine
is highly configurable to support both campaign mode (with fixed rules) and free
play mode (with user-adjustable rules).
Default values are based on the Mantimon TCG house rules documented in GAME_RULES.md,
which use a Pokemon Pocket-inspired energy system with a 40-card main deck and
separate 20-card energy deck.
Usage:
# Use default rules
rules = RulesConfig()
# Customize specific rules
rules = RulesConfig(
deck=DeckConfig(min_size=60, max_size=60),
prizes=PrizeConfig(count=6),
)
# Load from JSON
rules = RulesConfig.model_validate_json(json_string)
"""
from pydantic import BaseModel, Field
from app.core.models.enums import EnergyType, PokemonStage
class DeckConfig(BaseModel):
"""Configuration for deck building rules.
Attributes:
min_size: Minimum number of cards in the main deck.
max_size: Maximum number of cards in the main deck.
exact_size_required: If True, deck must be exactly min_size cards.
max_copies_per_card: Maximum copies of any single card (by name).
max_copies_basic_energy: Max copies of basic energy in energy deck.
None means unlimited.
min_basic_pokemon: Minimum number of Basic Pokemon required.
energy_deck_enabled: If True, use separate energy deck (Pokemon Pocket style).
energy_deck_size: Size of the separate energy deck.
"""
min_size: int = 40
max_size: int = 40
exact_size_required: bool = True
max_copies_per_card: int = 4
max_copies_basic_energy: int | None = None
min_basic_pokemon: int = 1
energy_deck_enabled: bool = True
energy_deck_size: int = 20
class BenchConfig(BaseModel):
"""Configuration for bench rules.
Attributes:
max_size: Maximum number of Pokemon on the bench.
"""
max_size: int = 5
class EnergyConfig(BaseModel):
"""Configuration for energy attachment rules.
Attributes:
attachments_per_turn: Number of energy cards that can be attached per turn.
types_enabled: List of energy types available in this game.
auto_flip_from_deck: If True, flip top card of energy deck at turn start
(Pokemon Pocket style).
"""
attachments_per_turn: int = 1
types_enabled: list[EnergyType] = Field(default_factory=lambda: list(EnergyType))
auto_flip_from_deck: bool = True
class PrizeConfig(BaseModel):
"""Configuration for prize/scoring rules.
In core Mantimon TCG rules, "prizes" are replaced with "points" - players
score points instead of taking prize cards. This simplifies the game while
maintaining the knockout scoring mechanic.
Attributes:
count: Number of points needed to win (or prize cards if using classic rules).
per_knockout_basic: Points scored for knocking out a basic Pokemon.
per_knockout_stage_1: Points scored for knocking out a Stage 1 Pokemon.
per_knockout_stage_2: Points scored for knocking out a Stage 2 Pokemon.
per_knockout_ex: Points scored for knocking out an EX Pokemon.
per_knockout_v: Points scored for knocking out a V Pokemon.
per_knockout_vmax: Points scored for knocking out a VMAX Pokemon.
per_knockout_gx: Points scored for knocking out a GX Pokemon.
use_prize_cards: If True, use classic prize card mechanic instead of points.
prize_selection_random: If True, prize cards are taken randomly (classic).
If False, player chooses which prize to take.
"""
count: int = 4
per_knockout_basic: int = 1
per_knockout_stage_1: int = 1
per_knockout_stage_2: int = 1
per_knockout_ex: int = 2
per_knockout_v: int = 2
per_knockout_vmax: int = 3
per_knockout_gx: int = 2
use_prize_cards: bool = False
prize_selection_random: bool = True
def points_for_knockout(self, stage: PokemonStage) -> int:
"""Get the number of points scored for knocking out a Pokemon of the given stage.
Args:
stage: The PokemonStage of the knocked out Pokemon.
Returns:
Number of points to score.
"""
stage_map = {
PokemonStage.BASIC: self.per_knockout_basic,
PokemonStage.STAGE_1: self.per_knockout_stage_1,
PokemonStage.STAGE_2: self.per_knockout_stage_2,
PokemonStage.EX: self.per_knockout_ex,
PokemonStage.V: self.per_knockout_v,
PokemonStage.VMAX: self.per_knockout_vmax,
PokemonStage.GX: self.per_knockout_gx,
}
return stage_map.get(stage, self.per_knockout_basic)
class FirstTurnConfig(BaseModel):
"""Configuration for first turn restrictions.
These rules apply only to the very first turn of the game (turn 1 for player 1).
Attributes:
can_draw: Whether the first player draws a card on turn 1.
can_attack: Whether the first player can attack on turn 1.
can_play_supporter: Whether the first player can play Supporter cards on turn 1.
can_attach_energy: Whether the first player can attach energy on turn 1.
can_evolve: Whether the first player can evolve Pokemon on turn 1.
"""
can_draw: bool = True
can_attack: bool = True
can_play_supporter: bool = True
can_attach_energy: bool = False
can_evolve: bool = False
class WinConditionsConfig(BaseModel):
"""Configuration for win/loss conditions.
Each condition can be enabled or disabled independently. A player wins
when any enabled win condition is met.
Attributes:
all_prizes_taken: Win when a player scores the required number of points.
no_pokemon_in_play: Win when opponent has no Pokemon in play.
cannot_draw: Win when opponent cannot draw a card at turn start.
turn_limit_enabled: Enable maximum turn count (useful for AI matches).
turn_limit: Maximum number of turns before game ends. Each player's
turn counts as one turn (so 30 = 15 turns per player).
turn_timer_enabled: Enable per-turn time limits (multiplayer).
turn_timer_seconds: Seconds per turn before timeout (default 90).
game_timer_enabled: Enable total game time limit (multiplayer).
game_timer_minutes: Total game time in minutes.
"""
all_prizes_taken: bool = True
no_pokemon_in_play: bool = True
cannot_draw: bool = True
turn_limit_enabled: bool = True
turn_limit: int = 30
turn_timer_enabled: bool = False
turn_timer_seconds: int = 90
game_timer_enabled: bool = False
game_timer_minutes: int = 30
class StatusConfig(BaseModel):
"""Configuration for status condition effects.
Defines the damage values and removal mechanics for each status condition.
Attributes:
poison_damage: Damage dealt by Poison between turns.
burn_damage: Damage dealt by Burn between turns.
burn_flip_to_remove: If True, flip coin between turns; heads removes Burn.
sleep_flip_to_wake: If True, flip coin between turns; heads removes Sleep.
confusion_self_damage: Damage dealt to self on failed confusion flip.
"""
poison_damage: int = 10
burn_damage: int = 20
burn_flip_to_remove: bool = True
sleep_flip_to_wake: bool = True
confusion_self_damage: int = 30
class TrainerConfig(BaseModel):
"""Configuration for Trainer card rules.
Attributes:
supporters_per_turn: Maximum Supporter cards playable per turn.
stadiums_per_turn: Maximum Stadium cards playable per turn.
items_per_turn: Maximum Item cards per turn. None means unlimited.
tools_per_pokemon: Maximum Tool cards attachable to one Pokemon.
"""
supporters_per_turn: int = 1
stadiums_per_turn: int = 1
items_per_turn: int | None = None
tools_per_pokemon: int = 1
class EvolutionConfig(BaseModel):
"""Configuration for evolution rules.
Attributes:
same_turn_as_played: Can evolve a Pokemon the same turn it was played.
same_turn_as_evolution: Can evolve a Pokemon the same turn it evolved.
first_turn_of_game: Can evolve on the very first turn of the game.
"""
same_turn_as_played: bool = False
same_turn_as_evolution: bool = False
first_turn_of_game: bool = False
class RetreatConfig(BaseModel):
"""Configuration for retreat rules.
Attributes:
retreats_per_turn: Maximum number of retreats allowed per turn.
free_retreat_cost: If True, retreating doesn't require discarding energy.
"""
retreats_per_turn: int = 1
free_retreat_cost: bool = False
class RulesConfig(BaseModel):
"""Master configuration for all game rules.
This is the top-level configuration object that contains all rule settings.
Default values are based on Mantimon TCG house rules (Pokemon Pocket-inspired
with 40-card decks, separate energy deck, and 4 points to win).
For standard Pokemon TCG rules, override with:
RulesConfig(
deck=DeckConfig(min_size=60, max_size=60, energy_deck_enabled=False),
prizes=PrizeConfig(count=6, use_prize_cards=True),
first_turn=FirstTurnConfig(can_attack=False, can_attach_energy=True),
)
Attributes:
deck: Deck building configuration.
bench: Bench configuration.
energy: Energy attachment configuration.
prizes: Prize/scoring configuration.
first_turn: First turn restrictions.
win_conditions: Win/loss condition configuration.
status: Status condition effect configuration.
trainer: Trainer card rule configuration.
evolution: Evolution rule configuration.
retreat: Retreat rule configuration.
"""
deck: DeckConfig = Field(default_factory=DeckConfig)
bench: BenchConfig = Field(default_factory=BenchConfig)
energy: EnergyConfig = Field(default_factory=EnergyConfig)
prizes: PrizeConfig = Field(default_factory=PrizeConfig)
first_turn: FirstTurnConfig = Field(default_factory=FirstTurnConfig)
win_conditions: WinConditionsConfig = Field(default_factory=WinConditionsConfig)
status: StatusConfig = Field(default_factory=StatusConfig)
trainer: TrainerConfig = Field(default_factory=TrainerConfig)
evolution: EvolutionConfig = Field(default_factory=EvolutionConfig)
retreat: RetreatConfig = Field(default_factory=RetreatConfig)
@classmethod
def standard_pokemon_tcg(cls) -> "RulesConfig":
"""Create a configuration approximating standard Pokemon TCG rules.
Returns:
RulesConfig with settings closer to official Pokemon TCG.
"""
return cls(
deck=DeckConfig(
min_size=60,
max_size=60,
energy_deck_enabled=False,
),
prizes=PrizeConfig(
count=6,
use_prize_cards=True,
),
first_turn=FirstTurnConfig(
can_attack=False,
can_play_supporter=False,
can_attach_energy=True,
),
)

View File

@ -0,0 +1,11 @@
"""Effect handler system for Mantimon TCG.
This module implements a data-driven effect system where card effects are
defined by effect IDs that map to handler functions. This allows cards to
be defined in JSON/database while effect logic lives in Python.
Key Components:
- base: EffectContext and EffectResult types
- registry: Effect handler registration and lookup
- handlers: Built-in effect handlers (deal_damage, heal, etc.)
"""

View File

@ -0,0 +1,8 @@
"""Core data models for the Mantimon TCG game engine.
This module contains all Pydantic models used throughout the game engine:
- enums: Enumeration types (CardType, EnergyType, TurnPhase, etc.)
- card: CardDefinition (template) and CardInstance (in-game state)
- actions: Player action types as a discriminated union
- game_state: GameState, PlayerState, and Zone models
"""

View File

@ -0,0 +1,168 @@
"""Enumeration types for the Mantimon TCG game engine.
This module defines all enum types used throughout the game engine. We use StrEnum
for JSON serialization compatibility - enum values serialize as strings directly.
Note on extensibility: While these enums define the standard types, the game engine
is designed to be configurable. Custom energy types or card types could be added
via configuration for free play mode.
"""
from enum import StrEnum
class CardType(StrEnum):
"""The primary type of a card.
Every card in the game belongs to exactly one of these types, which determines
how it can be played and what rules apply to it.
"""
POKEMON = "pokemon"
TRAINER = "trainer"
ENERGY = "energy"
class PokemonStage(StrEnum):
"""The evolution stage of a Pokemon card.
Determines how the Pokemon can be played:
- BASIC: Can be played directly from hand to bench
- STAGE_1: Must evolve from a Basic Pokemon
- STAGE_2: Must evolve from a Stage 1 Pokemon
- V: Special basic Pokemon worth 2 knockout points
- VMAX: Evolves from a V Pokemon, worth 3 knockout points
- EX: Can be basic, stage 1, or stage 2; worth 2 knockout points
- GX: Similar to EX, worth 2 knockout points
"""
BASIC = "basic"
STAGE_1 = "stage_1"
STAGE_2 = "stage_2"
V = "v"
VMAX = "vmax"
EX = "ex"
GX = "gx"
class EnergyType(StrEnum):
"""Energy types available in the game.
Based on modern Pokemon TCG with 10 types. Colorless is special - any energy
can satisfy colorless requirements.
Note: The engine supports all types, but specific games can restrict which
types are enabled via RulesConfig.
"""
COLORLESS = "colorless"
DARKNESS = "darkness"
DRAGON = "dragon"
FIGHTING = "fighting"
FIRE = "fire"
GRASS = "grass"
LIGHTNING = "lightning"
METAL = "metal"
PSYCHIC = "psychic"
WATER = "water"
class TrainerType(StrEnum):
"""Subtypes of Trainer cards.
Each subtype has different rules for how many can be played per turn:
- ITEM: Unlimited per turn
- SUPPORTER: One per turn
- STADIUM: One per turn, stays in play
- TOOL: Attached to Pokemon, unlimited per turn
"""
ITEM = "item"
SUPPORTER = "supporter"
STADIUM = "stadium"
TOOL = "tool"
class TurnPhase(StrEnum):
"""Phases within a player's turn.
Turn structure:
1. SETUP: Initial game setup (draw starting hand, place basics, set prizes)
2. DRAW: Draw a card from deck
3. MAIN: Play cards, attach energy, evolve, use abilities, retreat
4. ATTACK: Declare and resolve an attack (optional)
5. END: Apply end-of-turn effects, check knockouts, score points
Valid transitions:
- SETUP -> DRAW (game start, first player's turn)
- DRAW -> MAIN
- MAIN -> ATTACK or END (can skip attack)
- ATTACK -> END
- END -> DRAW (next player's turn)
"""
SETUP = "setup"
DRAW = "draw"
MAIN = "main"
ATTACK = "attack"
END = "end"
class StatusCondition(StrEnum):
"""Status conditions that can affect Pokemon in play.
Status conditions have specific effects and removal conditions:
- POISONED: 10 damage between turns; removed by evolution, retreat, or card effect
- BURNED: 20 damage between turns + flip to remove; removed on heads
- ASLEEP: Cannot attack or retreat; removed on heads flip; overrides PARALYZED/CONFUSED
- PARALYZED: Cannot attack or retreat for 1 turn; removed at end of next turn;
overrides ASLEEP/CONFUSED
- CONFUSED: Flip to attack, 30 self-damage on tails; removed by evolution/retreat;
overrides ASLEEP/PARALYZED
Note: POISONED and BURNED stack with other conditions. ASLEEP, PARALYZED, and
CONFUSED override each other (only one can be active at a time).
"""
POISONED = "poisoned"
BURNED = "burned"
ASLEEP = "asleep"
PARALYZED = "paralyzed"
CONFUSED = "confused"
class ActionType(StrEnum):
"""Types of actions a player can take during their turn.
Each action type has specific validation rules and can only be performed
during certain phases. See rules_validator.py for details.
"""
PLAY_POKEMON = "play_pokemon"
EVOLVE = "evolve"
ATTACH_ENERGY = "attach_energy"
PLAY_TRAINER = "play_trainer"
USE_ABILITY = "use_ability"
ATTACK = "attack"
RETREAT = "retreat"
PASS = "pass"
class GameEndReason(StrEnum):
"""Reasons why a game ended.
Used in GameState.end_reason to indicate how the game concluded:
- PRIZES_TAKEN: A player took all required prize points
- NO_POKEMON: A player has no Pokemon in play (active or bench)
- DECK_EMPTY: A player cannot draw a card at the start of their turn
- RESIGNATION: A player resigned from the match
- TIMEOUT: A player ran out of time (multiplayer only)
- DRAW: The game ended in a draw (tie on points at timer expiration)
"""
PRIZES_TAKEN = "prizes_taken"
NO_POKEMON = "no_pokemon"
DECK_EMPTY = "deck_empty"
RESIGNATION = "resignation"
TIMEOUT = "timeout"
DRAW = "draw"

241
backend/app/core/rng.py Normal file
View File

@ -0,0 +1,241 @@
"""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()

View File

@ -0,0 +1 @@
"""Tests for the Mantimon TCG core game engine."""

View File

@ -0,0 +1,542 @@
"""Tests for the RulesConfig and sub-configuration models.
These tests verify that:
1. Default values match Mantimon TCG house rules from GAME_RULES.md
2. Configuration can be customized via constructor or JSON
3. Nested configs work correctly
4. Helper methods function as expected
"""
import json
from app.core.config import (
BenchConfig,
DeckConfig,
EnergyConfig,
EvolutionConfig,
FirstTurnConfig,
PrizeConfig,
RetreatConfig,
RulesConfig,
StatusConfig,
TrainerConfig,
WinConditionsConfig,
)
from app.core.models.enums import EnergyType, PokemonStage
class TestDeckConfig:
"""Tests for DeckConfig."""
def test_default_values(self) -> None:
"""
Verify DeckConfig defaults match Mantimon TCG house rules.
Per GAME_RULES.md: 40-card main deck, separate 20-card energy deck.
"""
config = DeckConfig()
assert config.min_size == 40
assert config.max_size == 40
assert config.exact_size_required is True
assert config.max_copies_per_card == 4
assert config.max_copies_basic_energy is None # Unlimited
assert config.min_basic_pokemon == 1
assert config.energy_deck_enabled is True
assert config.energy_deck_size == 20
def test_custom_values(self) -> None:
"""
Verify DeckConfig can be customized.
This is important for free play mode where users can adjust rules.
"""
config = DeckConfig(
min_size=60,
max_size=60,
energy_deck_enabled=False,
)
assert config.min_size == 60
assert config.max_size == 60
assert config.energy_deck_enabled is False
# Other values should still be defaults
assert config.max_copies_per_card == 4
class TestBenchConfig:
"""Tests for BenchConfig."""
def test_default_values(self) -> None:
"""
Verify BenchConfig defaults to standard 5 Pokemon bench.
"""
config = BenchConfig()
assert config.max_size == 5
def test_custom_bench_size(self) -> None:
"""
Verify bench size can be customized for variant rules.
"""
config = BenchConfig(max_size=8)
assert config.max_size == 8
class TestEnergyConfig:
"""Tests for EnergyConfig."""
def test_default_values(self) -> None:
"""
Verify EnergyConfig defaults to Pokemon Pocket-style energy system.
"""
config = EnergyConfig()
assert config.attachments_per_turn == 1
assert config.auto_flip_from_deck is True
assert len(config.types_enabled) == 10
assert EnergyType.FIRE in config.types_enabled
assert EnergyType.COLORLESS in config.types_enabled
def test_all_energy_types_enabled_by_default(self) -> None:
"""
Verify all 10 energy types are enabled by default.
"""
config = EnergyConfig()
expected_types = set(EnergyType)
actual_types = set(config.types_enabled)
assert actual_types == expected_types
def test_restrict_energy_types(self) -> None:
"""
Verify energy types can be restricted for themed games.
"""
config = EnergyConfig(
types_enabled=[EnergyType.FIRE, EnergyType.WATER, EnergyType.COLORLESS]
)
assert len(config.types_enabled) == 3
assert EnergyType.FIRE in config.types_enabled
assert EnergyType.GRASS not in config.types_enabled
class TestPrizeConfig:
"""Tests for PrizeConfig."""
def test_default_values(self) -> None:
"""
Verify PrizeConfig defaults match Mantimon TCG house rules.
Per GAME_RULES.md: 4 points to win, points instead of prize cards.
"""
config = PrizeConfig()
assert config.count == 4
assert config.per_knockout_basic == 1
assert config.per_knockout_ex == 2
assert config.per_knockout_v == 2
assert config.per_knockout_vmax == 3
assert config.use_prize_cards is False
def test_points_for_knockout_basic(self) -> None:
"""
Verify points_for_knockout returns correct value for basic Pokemon.
"""
config = PrizeConfig()
assert config.points_for_knockout(PokemonStage.BASIC) == 1
def test_points_for_knockout_ex(self) -> None:
"""
Verify points_for_knockout returns 2 for EX Pokemon.
EX Pokemon are worth 2 knockout points per GAME_RULES.md.
"""
config = PrizeConfig()
assert config.points_for_knockout(PokemonStage.EX) == 2
def test_points_for_knockout_vmax(self) -> None:
"""
Verify points_for_knockout returns 3 for VMAX Pokemon.
VMAX are the highest-value Pokemon in the game.
"""
config = PrizeConfig()
assert config.points_for_knockout(PokemonStage.VMAX) == 3
def test_points_for_knockout_all_stages(self) -> None:
"""
Verify points_for_knockout works for all Pokemon stages.
"""
config = PrizeConfig()
assert config.points_for_knockout(PokemonStage.BASIC) == 1
assert config.points_for_knockout(PokemonStage.STAGE_1) == 1
assert config.points_for_knockout(PokemonStage.STAGE_2) == 1
assert config.points_for_knockout(PokemonStage.EX) == 2
assert config.points_for_knockout(PokemonStage.V) == 2
assert config.points_for_knockout(PokemonStage.VMAX) == 3
assert config.points_for_knockout(PokemonStage.GX) == 2
def test_custom_knockout_points(self) -> None:
"""
Verify knockout point values can be customized.
"""
config = PrizeConfig(per_knockout_basic=2, per_knockout_ex=3)
assert config.points_for_knockout(PokemonStage.BASIC) == 2
assert config.points_for_knockout(PokemonStage.EX) == 3
class TestFirstTurnConfig:
"""Tests for FirstTurnConfig."""
def test_default_values(self) -> None:
"""
Verify FirstTurnConfig defaults match Mantimon TCG house rules.
Per GAME_RULES.md:
- First player CAN draw, attack, and play supporters on turn 1
- First player CANNOT attach energy or evolve on turn 1
"""
config = FirstTurnConfig()
assert config.can_draw is True
assert config.can_attack is True
assert config.can_play_supporter is True
assert config.can_attach_energy is False
assert config.can_evolve is False
def test_standard_pokemon_tcg_first_turn(self) -> None:
"""
Verify configuration for standard Pokemon TCG first turn rules.
In standard rules, first player cannot attack but can attach energy.
"""
config = FirstTurnConfig(
can_attack=False,
can_play_supporter=False,
can_attach_energy=True,
)
assert config.can_attack is False
assert config.can_play_supporter is False
assert config.can_attach_energy is True
class TestWinConditionsConfig:
"""Tests for WinConditionsConfig."""
def test_default_values(self) -> None:
"""
Verify WinConditionsConfig defaults enable all standard win conditions.
"""
config = WinConditionsConfig()
assert config.all_prizes_taken is True
assert config.no_pokemon_in_play is True
assert config.cannot_draw is True
assert config.turn_limit_enabled is True
assert config.turn_limit == 30
assert config.turn_timer_enabled is False
assert config.turn_timer_seconds == 90
assert config.game_timer_enabled is False
assert config.game_timer_minutes == 30
def test_turn_limit_config(self) -> None:
"""
Verify turn limit settings for AI/single-player matches.
Turn limits are useful when real-time timers don't make sense,
such as playing against AI or in puzzle mode.
"""
config = WinConditionsConfig(
turn_limit_enabled=True,
turn_limit=20,
)
assert config.turn_limit_enabled is True
assert config.turn_limit == 20
def test_multiplayer_timer_config(self) -> None:
"""
Verify timer settings for multiplayer mode.
"""
config = WinConditionsConfig(
turn_timer_enabled=True,
turn_timer_seconds=120,
game_timer_enabled=True,
game_timer_minutes=45,
)
assert config.turn_timer_enabled is True
assert config.turn_timer_seconds == 120
assert config.game_timer_enabled is True
assert config.game_timer_minutes == 45
class TestStatusConfig:
"""Tests for StatusConfig."""
def test_default_values(self) -> None:
"""
Verify StatusConfig defaults match GAME_RULES.md.
Per documentation:
- Poison: 10 damage between turns
- Burn: 20 damage between turns, flip to remove
- Confusion: 30 self-damage on tails
"""
config = StatusConfig()
assert config.poison_damage == 10
assert config.burn_damage == 20
assert config.burn_flip_to_remove is True
assert config.sleep_flip_to_wake is True
assert config.confusion_self_damage == 30
class TestTrainerConfig:
"""Tests for TrainerConfig."""
def test_default_values(self) -> None:
"""
Verify TrainerConfig defaults match standard rules.
Per GAME_RULES.md:
- Items: Unlimited per turn
- Supporters: One per turn
- Stadiums: One per turn
- Tools: One per Pokemon
"""
config = TrainerConfig()
assert config.supporters_per_turn == 1
assert config.stadiums_per_turn == 1
assert config.items_per_turn is None # Unlimited
assert config.tools_per_pokemon == 1
class TestEvolutionConfig:
"""Tests for EvolutionConfig."""
def test_default_values(self) -> None:
"""
Verify EvolutionConfig defaults to standard evolution rules.
By default, Pokemon cannot evolve:
- The same turn they were played
- The same turn they already evolved
- On the first turn of the game
"""
config = EvolutionConfig()
assert config.same_turn_as_played is False
assert config.same_turn_as_evolution is False
assert config.first_turn_of_game is False
class TestRetreatConfig:
"""Tests for RetreatConfig."""
def test_default_values(self) -> None:
"""
Verify RetreatConfig defaults to standard retreat rules.
"""
config = RetreatConfig()
assert config.retreats_per_turn == 1
assert config.free_retreat_cost is False
class TestRulesConfig:
"""Tests for the master RulesConfig."""
def test_default_instantiation(self) -> None:
"""
Verify RulesConfig can be instantiated with all defaults.
All nested configs should be created with their own defaults.
"""
rules = RulesConfig()
assert rules.deck.min_size == 40
assert rules.bench.max_size == 5
assert rules.energy.attachments_per_turn == 1
assert rules.prizes.count == 4
assert rules.first_turn.can_attack is True
assert rules.win_conditions.all_prizes_taken is True
assert rules.status.poison_damage == 10
assert rules.trainer.supporters_per_turn == 1
assert rules.evolution.same_turn_as_played is False
assert rules.retreat.retreats_per_turn == 1
def test_partial_override(self) -> None:
"""
Verify RulesConfig allows partial overrides of nested configs.
Only the specified values should change; others remain default.
"""
rules = RulesConfig(
deck=DeckConfig(min_size=60, max_size=60),
prizes=PrizeConfig(count=6),
)
# Overridden values
assert rules.deck.min_size == 60
assert rules.deck.max_size == 60
assert rules.prizes.count == 6
# Default values still in place
assert rules.deck.max_copies_per_card == 4
assert rules.bench.max_size == 5
assert rules.first_turn.can_attack is True
def test_standard_pokemon_tcg_preset(self) -> None:
"""
Verify standard_pokemon_tcg() returns configuration for official rules.
This preset should override Mantimon defaults to match standard Pokemon TCG.
"""
rules = RulesConfig.standard_pokemon_tcg()
# Standard rules: 60-card deck, no energy deck
assert rules.deck.min_size == 60
assert rules.deck.max_size == 60
assert rules.deck.energy_deck_enabled is False
# Standard rules: 6 prizes with prize cards
assert rules.prizes.count == 6
assert rules.prizes.use_prize_cards is True
# Standard rules: first player cannot attack or play supporter
assert rules.first_turn.can_attack is False
assert rules.first_turn.can_play_supporter is False
assert rules.first_turn.can_attach_energy is True
def test_json_round_trip(self) -> None:
"""
Verify RulesConfig serializes and deserializes correctly.
This is critical for:
- Saving game configurations to database
- Sending rules to clients
- Loading preset rule configurations
"""
original = RulesConfig(
deck=DeckConfig(min_size=50),
prizes=PrizeConfig(count=5, per_knockout_ex=3),
)
json_str = original.model_dump_json()
restored = RulesConfig.model_validate_json(json_str)
assert restored.deck.min_size == 50
assert restored.prizes.count == 5
assert restored.prizes.per_knockout_ex == 3
# Defaults should be preserved
assert restored.bench.max_size == 5
def test_json_output_format(self) -> None:
"""
Verify JSON output uses expected field names for compatibility.
Field names in JSON should match the Python attribute names.
"""
rules = RulesConfig()
data = rules.model_dump()
# Verify top-level keys
assert "deck" in data
assert "bench" in data
assert "energy" in data
assert "prizes" in data
assert "first_turn" in data
assert "win_conditions" in data
assert "status" in data
assert "trainer" in data
assert "evolution" in data
assert "retreat" in data
# Verify nested keys
assert "min_size" in data["deck"]
assert "max_size" in data["deck"]
assert "attachments_per_turn" in data["energy"]
def test_nested_config_independence(self) -> None:
"""
Verify nested configs don't share state between RulesConfig instances.
Each RulesConfig should have its own copies of nested configs.
"""
rules1 = RulesConfig()
rules2 = RulesConfig()
# Modify rules1's nested config
rules1.deck.min_size = 100
# rules2 should be unaffected
assert rules2.deck.min_size == 40
class TestRulesConfigFromJson:
"""Tests for loading RulesConfig from JSON strings."""
def test_load_minimal_json(self) -> None:
"""
Verify RulesConfig can be loaded from minimal JSON.
Missing fields should use defaults.
"""
json_str = '{"prizes": {"count": 8}}'
rules = RulesConfig.model_validate_json(json_str)
assert rules.prizes.count == 8
assert rules.deck.min_size == 40 # Default
def test_load_full_json(self) -> None:
"""
Verify RulesConfig can be loaded from comprehensive JSON.
This tests the format documented in GAME_RULES.md.
"""
config_dict = {
"deck": {
"min_size": 60,
"max_size": 60,
"max_copies_per_card": 4,
"min_basic_pokemon": 1,
"energy_deck_enabled": False,
},
"prizes": {
"count": 6,
"per_knockout_basic": 1,
"per_knockout_ex": 2,
"use_prize_cards": True,
},
"energy": {"attachments_per_turn": 1},
"first_turn": {
"can_draw": True,
"can_attack": False,
"can_play_supporter": False,
},
"win_conditions": {
"all_prizes_taken": True,
"no_pokemon_in_play": True,
"cannot_draw": True,
},
}
json_str = json.dumps(config_dict)
rules = RulesConfig.model_validate_json(json_str)
assert rules.deck.min_size == 60
assert rules.deck.energy_deck_enabled is False
assert rules.prizes.count == 6
assert rules.prizes.use_prize_cards is True
assert rules.first_turn.can_attack is False
def test_empty_json_uses_all_defaults(self) -> None:
"""
Verify empty JSON object creates config with all defaults.
"""
rules = RulesConfig.model_validate_json("{}")
assert rules.deck.min_size == 40
assert rules.prizes.count == 4
assert rules.first_turn.can_attack is True

View File

@ -0,0 +1 @@
"""Tests for the effect handler system."""

View File

@ -0,0 +1 @@
"""Tests for core data models."""

View File

@ -0,0 +1,302 @@
"""Tests for the enumeration types in the game engine.
These tests verify that all enums:
1. Have expected values that won't accidentally change
2. Serialize correctly to JSON (as strings)
3. Can be used in Pydantic models
4. Support membership checks and iteration
"""
import json
import pytest
from pydantic import BaseModel
from app.core.models.enums import (
ActionType,
CardType,
EnergyType,
GameEndReason,
PokemonStage,
StatusCondition,
TrainerType,
TurnPhase,
)
class TestCardType:
"""Tests for the CardType enum."""
def test_card_type_values(self) -> None:
"""
Verify CardType has exactly three types with expected string values.
The three card types are fundamental to the game and should never change.
"""
assert CardType.POKEMON == "pokemon"
assert CardType.TRAINER == "trainer"
assert CardType.ENERGY == "energy"
assert len(CardType) == 3
def test_card_type_json_serialization(self) -> None:
"""
Verify CardType serializes to a plain string in JSON.
This is important for WebSocket messages and database storage.
"""
assert json.dumps(CardType.POKEMON) == '"pokemon"'
def test_card_type_in_pydantic_model(self) -> None:
"""
Verify CardType works correctly in Pydantic models.
Pydantic should accept both enum values and string values.
"""
class CardModel(BaseModel):
card_type: CardType
# Accept enum value
model = CardModel(card_type=CardType.POKEMON)
assert model.card_type == CardType.POKEMON
# Accept string value
model = CardModel(card_type="trainer") # type: ignore[arg-type]
assert model.card_type == CardType.TRAINER
# JSON round-trip
json_str = model.model_dump_json()
restored = CardModel.model_validate_json(json_str)
assert restored.card_type == CardType.TRAINER
class TestPokemonStage:
"""Tests for the PokemonStage enum."""
def test_pokemon_stage_values(self) -> None:
"""
Verify PokemonStage has all expected evolution stages.
Includes both classic stages (basic, stage 1, stage 2) and modern
variants (V, VMAX, EX, GX).
"""
assert PokemonStage.BASIC == "basic"
assert PokemonStage.STAGE_1 == "stage_1"
assert PokemonStage.STAGE_2 == "stage_2"
assert PokemonStage.V == "v"
assert PokemonStage.VMAX == "vmax"
assert PokemonStage.EX == "ex"
assert PokemonStage.GX == "gx"
assert len(PokemonStage) == 7
def test_pokemon_stage_membership(self) -> None:
"""
Verify membership checks work for PokemonStage.
This is used to validate card definitions.
"""
assert "basic" in [s.value for s in PokemonStage]
assert "invalid" not in [s.value for s in PokemonStage]
class TestEnergyType:
"""Tests for the EnergyType enum."""
def test_energy_type_values(self) -> None:
"""
Verify EnergyType has all 10 modern Pokemon TCG energy types.
The types should match the documented 10 types from GAME_RULES.md.
"""
expected_types = {
"colorless",
"darkness",
"dragon",
"fighting",
"fire",
"grass",
"lightning",
"metal",
"psychic",
"water",
}
actual_types = {e.value for e in EnergyType}
assert actual_types == expected_types
assert len(EnergyType) == 10
def test_colorless_is_special(self) -> None:
"""
Verify COLORLESS energy type exists.
Colorless has special meaning - any energy can satisfy colorless requirements.
This test documents that assumption.
"""
assert EnergyType.COLORLESS == "colorless"
assert EnergyType.COLORLESS in EnergyType
def test_energy_type_iteration(self) -> None:
"""
Verify EnergyType can be iterated for building default configs.
The engine uses list(EnergyType) to get all available types.
"""
all_types = list(EnergyType)
assert len(all_types) == 10
assert all(isinstance(t, EnergyType) for t in all_types)
class TestTrainerType:
"""Tests for the TrainerType enum."""
def test_trainer_type_values(self) -> None:
"""
Verify TrainerType has all four trainer subtypes.
These match the modern Pokemon TCG trainer card structure.
"""
assert TrainerType.ITEM == "item"
assert TrainerType.SUPPORTER == "supporter"
assert TrainerType.STADIUM == "stadium"
assert TrainerType.TOOL == "tool"
assert len(TrainerType) == 4
class TestTurnPhase:
"""Tests for the TurnPhase enum."""
def test_turn_phase_values(self) -> None:
"""
Verify TurnPhase has all five phases in the turn structure.
The phases follow the order: SETUP -> DRAW -> MAIN -> ATTACK -> END.
"""
assert TurnPhase.SETUP == "setup"
assert TurnPhase.DRAW == "draw"
assert TurnPhase.MAIN == "main"
assert TurnPhase.ATTACK == "attack"
assert TurnPhase.END == "end"
assert len(TurnPhase) == 5
def test_turn_phase_order(self) -> None:
"""
Verify turn phases can be compared for ordering.
While we don't rely on enum ordering for logic, this documents
the expected sequence.
"""
phases = list(TurnPhase)
assert phases[0] == TurnPhase.SETUP
assert phases[-1] == TurnPhase.END
class TestStatusCondition:
"""Tests for the StatusCondition enum."""
def test_status_condition_values(self) -> None:
"""
Verify StatusCondition has all five status conditions.
These match the status conditions documented in GAME_RULES.md.
"""
assert StatusCondition.POISONED == "poisoned"
assert StatusCondition.BURNED == "burned"
assert StatusCondition.ASLEEP == "asleep"
assert StatusCondition.PARALYZED == "paralyzed"
assert StatusCondition.CONFUSED == "confused"
assert len(StatusCondition) == 5
def test_stacking_conditions(self) -> None:
"""
Document which conditions can stack with others.
POISONED and BURNED can stack with any condition.
ASLEEP, PARALYZED, and CONFUSED override each other.
"""
stacking_conditions = {StatusCondition.POISONED, StatusCondition.BURNED}
overriding_conditions = {
StatusCondition.ASLEEP,
StatusCondition.PARALYZED,
StatusCondition.CONFUSED,
}
# Verify all conditions are accounted for
all_conditions = set(StatusCondition)
assert stacking_conditions | overriding_conditions == all_conditions
class TestActionType:
"""Tests for the ActionType enum."""
def test_action_type_values(self) -> None:
"""
Verify ActionType has all expected player actions.
These are all the actions a player can take during their turn.
"""
expected_actions = {
"play_pokemon",
"evolve",
"attach_energy",
"play_trainer",
"use_ability",
"attack",
"retreat",
"pass",
}
actual_actions = {a.value for a in ActionType}
assert actual_actions == expected_actions
assert len(ActionType) == 8
class TestGameEndReason:
"""Tests for the GameEndReason enum."""
def test_game_end_reason_values(self) -> None:
"""
Verify GameEndReason has all expected game ending conditions.
These match the win conditions documented in GAME_RULES.md plus
additional end conditions like resignation and timeout.
"""
assert GameEndReason.PRIZES_TAKEN == "prizes_taken"
assert GameEndReason.NO_POKEMON == "no_pokemon"
assert GameEndReason.DECK_EMPTY == "deck_empty"
assert GameEndReason.RESIGNATION == "resignation"
assert GameEndReason.TIMEOUT == "timeout"
assert GameEndReason.DRAW == "draw"
assert len(GameEndReason) == 6
class TestEnumJsonRoundTrip:
"""Tests for JSON serialization/deserialization of all enums."""
@pytest.mark.parametrize(
"enum_class,sample_value",
[
(CardType, CardType.POKEMON),
(PokemonStage, PokemonStage.BASIC),
(EnergyType, EnergyType.FIRE),
(TrainerType, TrainerType.SUPPORTER),
(TurnPhase, TurnPhase.MAIN),
(StatusCondition, StatusCondition.POISONED),
(ActionType, ActionType.ATTACK),
(GameEndReason, GameEndReason.PRIZES_TAKEN),
],
)
def test_json_round_trip(self, enum_class: type, sample_value: str) -> None:
"""
Verify all enum types round-trip through JSON correctly.
This is critical for WebSocket communication and state persistence.
Each enum should serialize to its string value and deserialize back.
"""
class TestModel(BaseModel):
value: enum_class # type: ignore[valid-type]
model = TestModel(value=sample_value)
json_str = model.model_dump_json()
restored = TestModel.model_validate_json(json_str)
assert restored.value == sample_value
assert isinstance(restored.value, enum_class)

View File

@ -0,0 +1,511 @@
"""Tests for the RandomProvider implementations.
These tests verify:
1. SeededRandom produces deterministic results with the same seed
2. SecureRandom produces varied results
3. Both implementations satisfy the RandomProvider protocol
4. Edge cases are handled correctly
"""
import pytest
from app.core.rng import RandomProvider, SecureRandom, SeededRandom, create_rng
class TestSeededRandom:
"""Tests for the SeededRandom implementation."""
def test_same_seed_produces_same_sequence(self) -> None:
"""
Verify that two SeededRandom instances with the same seed produce
identical sequences of random values.
This is critical for testing game logic that depends on randomness.
"""
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
# Generate sequences of values
seq1 = [rng1.randint(1, 100) for _ in range(10)]
seq2 = [rng2.randint(1, 100) for _ in range(10)]
assert seq1 == seq2
def test_different_seeds_produce_different_sequences(self) -> None:
"""
Verify that different seeds produce different sequences.
This validates that the seed actually affects the output.
"""
rng1 = SeededRandom(seed=1)
rng2 = SeededRandom(seed=2)
seq1 = [rng1.randint(1, 1000) for _ in range(10)]
seq2 = [rng2.randint(1, 1000) for _ in range(10)]
assert seq1 != seq2
def test_random_returns_float_in_range(self) -> None:
"""
Verify random() returns floats in [0.0, 1.0).
"""
rng = SeededRandom(seed=42)
for _ in range(100):
value = rng.random()
assert 0.0 <= value < 1.0
def test_randint_returns_value_in_range(self) -> None:
"""
Verify randint() returns integers in the specified inclusive range.
"""
rng = SeededRandom(seed=42)
for _ in range(100):
value = rng.randint(1, 6)
assert 1 <= value <= 6
def test_randint_with_equal_bounds(self) -> None:
"""
Verify randint() works when a == b.
"""
rng = SeededRandom(seed=42)
assert rng.randint(5, 5) == 5
def test_choice_selects_from_sequence(self) -> None:
"""
Verify choice() returns an element from the sequence.
"""
rng = SeededRandom(seed=42)
options = ["a", "b", "c", "d"]
for _ in range(100):
assert rng.choice(options) in options
def test_choice_deterministic(self) -> None:
"""
Verify choice() is deterministic with same seed.
"""
options = ["fire", "water", "grass"]
rng1 = SeededRandom(seed=123)
rng2 = SeededRandom(seed=123)
choices1 = [rng1.choice(options) for _ in range(10)]
choices2 = [rng2.choice(options) for _ in range(10)]
assert choices1 == choices2
def test_shuffle_is_deterministic(self) -> None:
"""
Verify shuffle() produces same order with same seed.
This is important for testing deck shuffling.
"""
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
list1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
list2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
rng1.shuffle(list1)
rng2.shuffle(list2)
assert list1 == list2
def test_shuffle_modifies_in_place(self) -> None:
"""
Verify shuffle() modifies the list in place and returns None.
"""
rng = SeededRandom(seed=42)
original = [1, 2, 3, 4, 5]
to_shuffle = original.copy()
result = rng.shuffle(to_shuffle)
assert result is None
# With high probability, shuffled list differs from original
# (1/120 chance they're the same for 5 elements)
# Use a seed known to produce a different order
assert to_shuffle != [1, 2, 3, 4, 5]
def test_coin_flip_is_deterministic(self) -> None:
"""
Verify coin_flip() produces same sequence with same seed.
This is critical for testing status condition removal.
"""
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
flips1 = [rng1.coin_flip() for _ in range(20)]
flips2 = [rng2.coin_flip() for _ in range(20)]
assert flips1 == flips2
def test_coin_flip_produces_both_outcomes(self) -> None:
"""
Verify coin_flip() can produce both True and False.
With 1000 flips, we should see both outcomes.
"""
rng = SeededRandom(seed=42)
flips = [rng.coin_flip() for _ in range(1000)]
assert True in flips
assert False in flips
def test_sample_returns_correct_count(self) -> None:
"""
Verify sample() returns exactly k elements.
"""
rng = SeededRandom(seed=42)
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == 10
def test_sample_returns_unique_elements(self) -> None:
"""
Verify sample() returns unique elements (no duplicates).
"""
rng = SeededRandom(seed=42)
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == len(set(result))
def test_sample_elements_from_population(self) -> None:
"""
Verify sample() only returns elements from the population.
"""
rng = SeededRandom(seed=42)
population = ["a", "b", "c", "d", "e"]
result = rng.sample(population, 3)
for item in result:
assert item in population
def test_sample_is_deterministic(self) -> None:
"""
Verify sample() is deterministic with same seed.
"""
population = list(range(50))
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
sample1 = rng1.sample(population, 5)
sample2 = rng2.sample(population, 5)
assert sample1 == sample2
def test_seed_property(self) -> None:
"""
Verify the seed property returns the initialization seed.
"""
rng = SeededRandom(seed=12345)
assert rng.seed == 12345
def test_none_seed_stored(self) -> None:
"""
Verify None seed is stored correctly.
"""
rng = SeededRandom(seed=None)
assert rng.seed is None
class TestSecureRandom:
"""Tests for the SecureRandom implementation."""
def test_random_returns_float_in_range(self) -> None:
"""
Verify random() returns floats in [0.0, 1.0).
"""
rng = SecureRandom()
for _ in range(100):
value = rng.random()
assert 0.0 <= value < 1.0
def test_randint_returns_value_in_range(self) -> None:
"""
Verify randint() returns integers in the specified inclusive range.
"""
rng = SecureRandom()
for _ in range(100):
value = rng.randint(1, 6)
assert 1 <= value <= 6
def test_randint_with_equal_bounds(self) -> None:
"""
Verify randint() works when a == b.
"""
rng = SecureRandom()
assert rng.randint(5, 5) == 5
def test_randint_invalid_range_raises(self) -> None:
"""
Verify randint() raises ValueError when a > b.
"""
rng = SecureRandom()
with pytest.raises(ValueError, match="must be <="):
rng.randint(10, 5)
def test_choice_selects_from_sequence(self) -> None:
"""
Verify choice() returns an element from the sequence.
"""
rng = SecureRandom()
options = ["a", "b", "c", "d"]
for _ in range(100):
assert rng.choice(options) in options
def test_choice_empty_sequence_raises(self) -> None:
"""
Verify choice() raises IndexError on empty sequence.
"""
rng = SecureRandom()
with pytest.raises(IndexError, match="empty sequence"):
rng.choice([])
def test_shuffle_changes_order(self) -> None:
"""
Verify shuffle() changes the order of elements.
Note: There's a tiny chance this fails if shuffle happens
to produce the original order, but it's negligible for
lists of reasonable size.
"""
rng = SecureRandom()
original = list(range(20))
shuffled = original.copy()
rng.shuffle(shuffled)
# Very unlikely to be in original order
assert shuffled != original
def test_shuffle_preserves_elements(self) -> None:
"""
Verify shuffle() preserves all elements.
"""
rng = SecureRandom()
original = [1, 2, 3, 4, 5]
shuffled = original.copy()
rng.shuffle(shuffled)
assert sorted(shuffled) == sorted(original)
def test_coin_flip_produces_both_outcomes(self) -> None:
"""
Verify coin_flip() produces both True and False.
"""
rng = SecureRandom()
flips = [rng.coin_flip() for _ in range(100)]
assert True in flips
assert False in flips
def test_sample_returns_correct_count(self) -> None:
"""
Verify sample() returns exactly k elements.
"""
rng = SecureRandom()
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == 10
def test_sample_returns_unique_elements(self) -> None:
"""
Verify sample() returns unique elements.
"""
rng = SecureRandom()
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == len(set(result))
def test_sample_too_large_raises(self) -> None:
"""
Verify sample() raises ValueError when k > population size.
"""
rng = SecureRandom()
with pytest.raises(ValueError, match="larger than population"):
rng.sample([1, 2, 3], 5)
def test_sample_negative_raises(self) -> None:
"""
Verify sample() raises ValueError for negative k.
"""
rng = SecureRandom()
with pytest.raises(ValueError, match="non-negative"):
rng.sample([1, 2, 3], -1)
def test_produces_varied_results(self) -> None:
"""
Verify SecureRandom produces varied results across calls.
This is a statistical test - we check that repeated calls
don't all return the same value.
"""
rng = SecureRandom()
# Generate many random values
values = [rng.randint(1, 1000000) for _ in range(100)]
# Should have many unique values
unique_values = set(values)
assert len(unique_values) > 90 # At least 90% unique
class TestRandomProviderProtocol:
"""Tests verifying both implementations satisfy the protocol."""
@pytest.mark.parametrize("rng_class", [SeededRandom, SecureRandom])
def test_implements_random_provider(self, rng_class: type) -> None:
"""
Verify both implementations can be used as RandomProvider.
This ensures the protocol is properly implemented.
"""
if rng_class == SeededRandom:
rng: RandomProvider = rng_class(seed=42)
else:
rng = rng_class()
# All protocol methods should be callable
assert callable(rng.random)
assert callable(rng.randint)
assert callable(rng.choice)
assert callable(rng.shuffle)
assert callable(rng.coin_flip)
assert callable(rng.sample)
@pytest.mark.parametrize("rng_class", [SeededRandom, SecureRandom])
def test_methods_return_correct_types(self, rng_class: type) -> None:
"""
Verify protocol methods return expected types.
"""
if rng_class == SeededRandom:
rng: RandomProvider = rng_class(seed=42)
else:
rng = rng_class()
assert isinstance(rng.random(), float)
assert isinstance(rng.randint(1, 10), int)
assert isinstance(rng.choice([1, 2, 3]), int)
assert isinstance(rng.coin_flip(), bool)
assert isinstance(rng.sample([1, 2, 3, 4, 5], 2), list)
class TestCreateRng:
"""Tests for the create_rng factory function."""
def test_with_seed_returns_seeded_random(self) -> None:
"""
Verify create_rng with seed returns SeededRandom.
"""
rng = create_rng(seed=42)
assert isinstance(rng, SeededRandom)
assert rng.seed == 42
def test_with_secure_returns_secure_random(self) -> None:
"""
Verify create_rng with secure=True returns SecureRandom.
"""
rng = create_rng(secure=True)
assert isinstance(rng, SecureRandom)
def test_default_returns_seeded_random(self) -> None:
"""
Verify create_rng with no args returns unseeded SeededRandom.
"""
rng = create_rng()
assert isinstance(rng, SeededRandom)
def test_seed_takes_precedence_over_secure(self) -> None:
"""
Verify that providing a seed returns SeededRandom even if secure=True.
The seed parameter takes precedence because if you're specifying a seed,
you clearly want reproducibility.
"""
rng = create_rng(seed=42, secure=True)
assert isinstance(rng, SeededRandom)
assert rng.seed == 42
class TestGameUseCases:
"""Tests simulating actual game engine use cases."""
def test_deck_shuffle_reproducibility(self) -> None:
"""
Verify deck shuffling is reproducible for testing.
This simulates initializing a game with the same seed
and verifying the deck order is identical.
"""
deck = list(range(1, 41)) # 40 card deck
rng1 = SeededRandom(seed=12345)
deck1 = deck.copy()
rng1.shuffle(deck1)
rng2 = SeededRandom(seed=12345)
deck2 = deck.copy()
rng2.shuffle(deck2)
assert deck1 == deck2
def test_coin_flip_for_status_removal(self) -> None:
"""
Verify coin flips work correctly for status condition removal.
With a known seed, we can test specific scenarios.
"""
rng = SeededRandom(seed=42)
# Simulate checking if burn is removed (flip at end of turn)
flips = [rng.coin_flip() for _ in range(10)]
# We don't care about the specific values, just that they're
# consistent and boolean
assert all(isinstance(f, bool) for f in flips)
def test_prize_card_selection(self) -> None:
"""
Verify random prize card selection works correctly.
When using classic prize card rules, a random card must be selected.
"""
rng = SeededRandom(seed=42)
prize_cards = ["card_1", "card_2", "card_3", "card_4", "card_5", "card_6"]
selected = rng.choice(prize_cards)
assert selected in prize_cards
def test_damage_calculation_with_randomness(self) -> None:
"""
Verify random damage modifiers work correctly.
Some attacks might have random damage (e.g., "flip a coin, if heads +20").
"""
rng = SeededRandom(seed=42)
base_damage = 30
# Simulate attack with coin flip bonus
total_damage = base_damage + 20 if rng.coin_flip() else base_damage
# Just verify the result is one of the two possibilities
assert total_damage in [30, 50]