From 3e82280efb3a40474e878878e9455b9626f974f4 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 24 Jan 2026 22:14:45 -0600 Subject: [PATCH] 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. --- backend/PROJECT_PLAN.json | 652 +++++++++++++++++++ backend/app/core/__init__.py | 17 + backend/app/core/config.py | 305 +++++++++ backend/app/core/effects/__init__.py | 11 + backend/app/core/models/__init__.py | 8 + backend/app/core/models/enums.py | 168 +++++ backend/app/core/rng.py | 241 +++++++ backend/tests/core/__init__.py | 1 + backend/tests/core/test_config.py | 542 +++++++++++++++ backend/tests/core/test_effects/__init__.py | 1 + backend/tests/core/test_models/__init__.py | 1 + backend/tests/core/test_models/test_enums.py | 302 +++++++++ backend/tests/core/test_rng.py | 511 +++++++++++++++ 13 files changed, 2760 insertions(+) create mode 100644 backend/PROJECT_PLAN.json create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/effects/__init__.py create mode 100644 backend/app/core/models/__init__.py create mode 100644 backend/app/core/models/enums.py create mode 100644 backend/app/core/rng.py create mode 100644 backend/tests/core/__init__.py create mode 100644 backend/tests/core/test_config.py create mode 100644 backend/tests/core/test_effects/__init__.py create mode 100644 backend/tests/core/test_models/__init__.py create mode 100644 backend/tests/core/test_models/test_enums.py create mode 100644 backend/tests/core/test_rng.py diff --git a/backend/PROJECT_PLAN.json b/backend/PROJECT_PLAN.json new file mode 100644 index 0000000..d7ca75c --- /dev/null +++ b/backend/PROJECT_PLAN.json @@ -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 + } + ] +} diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..edb3d09 --- /dev/null +++ b/backend/app/core/__init__.py @@ -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 +""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..96031dc --- /dev/null +++ b/backend/app/core/config.py @@ -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, + ), + ) diff --git a/backend/app/core/effects/__init__.py b/backend/app/core/effects/__init__.py new file mode 100644 index 0000000..3945d0b --- /dev/null +++ b/backend/app/core/effects/__init__.py @@ -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.) +""" diff --git a/backend/app/core/models/__init__.py b/backend/app/core/models/__init__.py new file mode 100644 index 0000000..6d77129 --- /dev/null +++ b/backend/app/core/models/__init__.py @@ -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 +""" diff --git a/backend/app/core/models/enums.py b/backend/app/core/models/enums.py new file mode 100644 index 0000000..073a29d --- /dev/null +++ b/backend/app/core/models/enums.py @@ -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" diff --git a/backend/app/core/rng.py b/backend/app/core/rng.py new file mode 100644 index 0000000..e68ef39 --- /dev/null +++ b/backend/app/core/rng.py @@ -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() diff --git a/backend/tests/core/__init__.py b/backend/tests/core/__init__.py new file mode 100644 index 0000000..8e57007 --- /dev/null +++ b/backend/tests/core/__init__.py @@ -0,0 +1 @@ +"""Tests for the Mantimon TCG core game engine.""" diff --git a/backend/tests/core/test_config.py b/backend/tests/core/test_config.py new file mode 100644 index 0000000..732ead0 --- /dev/null +++ b/backend/tests/core/test_config.py @@ -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 diff --git a/backend/tests/core/test_effects/__init__.py b/backend/tests/core/test_effects/__init__.py new file mode 100644 index 0000000..b619ed4 --- /dev/null +++ b/backend/tests/core/test_effects/__init__.py @@ -0,0 +1 @@ +"""Tests for the effect handler system.""" diff --git a/backend/tests/core/test_models/__init__.py b/backend/tests/core/test_models/__init__.py new file mode 100644 index 0000000..9ce00d3 --- /dev/null +++ b/backend/tests/core/test_models/__init__.py @@ -0,0 +1 @@ +"""Tests for core data models.""" diff --git a/backend/tests/core/test_models/test_enums.py b/backend/tests/core/test_models/test_enums.py new file mode 100644 index 0000000..2ff2524 --- /dev/null +++ b/backend/tests/core/test_models/test_enums.py @@ -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) diff --git a/backend/tests/core/test_rng.py b/backend/tests/core/test_rng.py new file mode 100644 index 0000000..586c337 --- /dev/null +++ b/backend/tests/core/test_rng.py @@ -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]