Add game engine foundation: enums, config, and RNG modules
- Create core module structure with models and effects subdirectories - Add enums module with CardType, EnergyType, TurnPhase, StatusCondition, etc. - Add RulesConfig with Mantimon TCG defaults (40-card deck, 4 points to win) - Add RandomProvider protocol with SeededRandom (testing) and SecureRandom (production) - Include comprehensive tests for all modules (97 tests passing) Defaults reflect GAME_RULES.md: Pokemon Pocket-style energy deck, first turn can attack but not attach energy, 30-turn limit enabled.
This commit is contained in:
parent
2cb99e9676
commit
3e82280efb
652
backend/PROJECT_PLAN.json
Normal file
652
backend/PROJECT_PLAN.json
Normal file
@ -0,0 +1,652 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-01-24",
|
||||
"lastUpdated": "2026-01-24",
|
||||
"planType": "feature",
|
||||
"projectName": "Mantimon TCG - Backend Game Engine",
|
||||
"description": "Core game engine scaffolding for a highly configurable Pokemon TCG-inspired card game. The engine must support campaign mode with fixed rules and free play mode with user-configurable rules.",
|
||||
"totalEstimatedHours": 48,
|
||||
"totalTasks": 32,
|
||||
"completedTasks": 0
|
||||
},
|
||||
"categories": {
|
||||
"critical": "Foundation components that block all other work",
|
||||
"high": "Core engine functionality required for basic gameplay",
|
||||
"medium": "Important features for complete gameplay experience",
|
||||
"low": "Polish, optimization, and nice-to-have features",
|
||||
"feature": "New capabilities beyond MVP"
|
||||
},
|
||||
"architectureDecisions": {
|
||||
"configurability": "All game rules driven by RulesConfig - defaults for campaign, user-adjustable for free play",
|
||||
"rngHandling": "RandomProvider protocol with SeededRandom (tests/replays) and SecureRandom (production PvP)",
|
||||
"cardRegistry": "Hybrid - definitions loaded from DB via CardService, embedded in GameState at game creation for self-contained gameplay",
|
||||
"cardModels": "Separate CardDefinition (immutable template) from CardInstance (mutable in-game state)",
|
||||
"actionModeling": "Union types for type safety",
|
||||
"asyncSupport": "Async throughout for WebSocket compatibility"
|
||||
},
|
||||
"directoryStructure": {
|
||||
"core": "backend/app/core/",
|
||||
"models": "backend/app/core/models/",
|
||||
"effects": "backend/app/core/effects/",
|
||||
"tests": "backend/tests/core/"
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "CRIT-001",
|
||||
"name": "Create core module structure",
|
||||
"description": "Set up the directory structure and __init__.py files for the core game engine module hierarchy",
|
||||
"category": "critical",
|
||||
"priority": 1,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{"path": "app/core/__init__.py", "issue": "File does not exist"},
|
||||
{"path": "app/core/models/__init__.py", "issue": "File does not exist"},
|
||||
{"path": "app/core/effects/__init__.py", "issue": "File does not exist"},
|
||||
{"path": "tests/core/__init__.py", "issue": "File does not exist"},
|
||||
{"path": "tests/core/test_models/__init__.py", "issue": "File does not exist"},
|
||||
{"path": "tests/core/test_effects/__init__.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Create all directories and empty __init__.py files with appropriate module docstrings",
|
||||
"estimatedHours": 0.5,
|
||||
"notes": "Foundation task - must be completed first"
|
||||
},
|
||||
{
|
||||
"id": "CRIT-002",
|
||||
"name": "Create enums module",
|
||||
"description": "Define all enumeration types used throughout the game engine: CardType, EnergyType, PokemonStage, TurnPhase, StatusCondition, TrainerType, ActionType",
|
||||
"category": "critical",
|
||||
"priority": 2,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-001"],
|
||||
"files": [
|
||||
{"path": "app/core/models/enums.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Create StrEnum classes for each enumeration type. Use StrEnum for JSON serialization compatibility.",
|
||||
"estimatedHours": 1,
|
||||
"notes": "All other modules depend on these enums. Consider future extensibility for custom energy types."
|
||||
},
|
||||
{
|
||||
"id": "TEST-001",
|
||||
"name": "Create enums tests",
|
||||
"description": "Test that all enums serialize correctly to JSON, have expected values, and can be used in Pydantic models",
|
||||
"category": "high",
|
||||
"priority": 3,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_models/test_enums.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test each enum: value consistency, JSON round-trip, membership checks",
|
||||
"estimatedHours": 0.5,
|
||||
"notes": "Simple tests but establish testing patterns for the project"
|
||||
},
|
||||
{
|
||||
"id": "CRIT-003",
|
||||
"name": "Create RulesConfig module",
|
||||
"description": "Define the master configuration system for all game rules with sensible defaults. Includes: DeckConfig, BenchConfig, EnergyConfig, PrizeConfig, FirstTurnConfig, WinConditionsConfig, StatusConfig, TrainerConfig",
|
||||
"category": "critical",
|
||||
"priority": 4,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{"path": "app/core/config.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Create nested Pydantic BaseModel classes with Field defaults. Use Field(default_factory=...) for mutable defaults.",
|
||||
"estimatedHours": 2,
|
||||
"notes": "This is the heart of the configurability requirement. Defaults should approximate standard Pokemon TCG rules. Document each config option thoroughly."
|
||||
},
|
||||
{
|
||||
"id": "TEST-002",
|
||||
"name": "Create RulesConfig tests",
|
||||
"description": "Test that RulesConfig has sensible defaults, custom values override correctly, and serialization round-trips work",
|
||||
"category": "high",
|
||||
"priority": 5,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-003"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_config.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: default instantiation, partial overrides, full JSON round-trip, validation of invalid values",
|
||||
"estimatedHours": 1,
|
||||
"notes": "Important to verify all defaults match expected standard rules"
|
||||
},
|
||||
{
|
||||
"id": "CRIT-004",
|
||||
"name": "Create RandomProvider module",
|
||||
"description": "Implement the RandomProvider protocol with SeededRandom (for testing/replays) and SecureRandom (for production PvP) implementations",
|
||||
"category": "critical",
|
||||
"priority": 6,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-001"],
|
||||
"files": [
|
||||
{"path": "app/core/rng.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Create Protocol class with random(), randint(), choice(), shuffle() methods. Implement SeededRandom using random.Random with seed, SecureRandom using secrets module.",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Critical for testability. SeededRandom enables deterministic tests for coin flips and shuffles."
|
||||
},
|
||||
{
|
||||
"id": "TEST-003",
|
||||
"name": "Create RandomProvider tests",
|
||||
"description": "Test that SeededRandom produces deterministic results and SecureRandom produces varied results",
|
||||
"category": "high",
|
||||
"priority": 7,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_rng.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: SeededRandom with same seed produces identical sequences, SecureRandom produces different values across calls (statistical test)",
|
||||
"estimatedHours": 1,
|
||||
"notes": "SeededRandom tests should be fully deterministic. SecureRandom tests should verify randomness."
|
||||
},
|
||||
{
|
||||
"id": "HIGH-001",
|
||||
"name": "Create CardDefinition and CardInstance models",
|
||||
"description": "Define the card template (CardDefinition) and in-game card state (CardInstance) models. Includes Attack, Ability sub-models.",
|
||||
"category": "high",
|
||||
"priority": 8,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{"path": "app/core/models/card.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "CardDefinition: immutable template with id, name, card_type, stage, hp, attacks, abilities, weakness, resistance, retreat_cost. CardInstance: mutable state with instance_id, definition_id, damage, attached_energy, status_conditions, turn_played.",
|
||||
"estimatedHours": 2,
|
||||
"notes": "CardInstance should only store mutable state. All static card data comes from CardDefinition lookup."
|
||||
},
|
||||
{
|
||||
"id": "TEST-004",
|
||||
"name": "Create card model tests",
|
||||
"description": "Test CardDefinition and CardInstance creation, validation, and serialization",
|
||||
"category": "high",
|
||||
"priority": 9,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-001"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_models/test_card.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: Pokemon card creation with attacks, Trainer card creation, Energy card creation, CardInstance damage tracking, energy attachment, status conditions",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Create sample card fixtures for reuse in other tests"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-002",
|
||||
"name": "Create Action union types",
|
||||
"description": "Define all player action types as Pydantic models with Literal type discriminators: PlayPokemonAction, EvolvePokemonAction, AttachEnergyAction, PlayTrainerAction, UseAbilityAction, AttackAction, RetreatAction, PassAction",
|
||||
"category": "high",
|
||||
"priority": 10,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-002"],
|
||||
"files": [
|
||||
{"path": "app/core/models/actions.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Each action model has a 'type' field with Literal value for discrimination. Create Action = Union[...] type alias for all actions.",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Union type enables type-safe action handling. Each action should have all parameters needed for validation and execution."
|
||||
},
|
||||
{
|
||||
"id": "TEST-005",
|
||||
"name": "Create action model tests",
|
||||
"description": "Test that action union types discriminate correctly and serialize/deserialize properly",
|
||||
"category": "high",
|
||||
"priority": 11,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-002"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_models/test_actions.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: each action type creates correctly, JSON round-trip works, discriminated union parses correct type",
|
||||
"estimatedHours": 1,
|
||||
"notes": "Test that parsing JSON into Action union correctly identifies the specific action type"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-003",
|
||||
"name": "Create GameState, PlayerState, and Zone models",
|
||||
"description": "Define the complete game state model hierarchy: Zone (card collection with operations), PlayerState (all player zones and turn state), GameState (full game including card_registry, rules, players, turn tracking)",
|
||||
"category": "high",
|
||||
"priority": 12,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-003", "HIGH-001"],
|
||||
"files": [
|
||||
{"path": "app/core/models/game_state.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Zone: list of CardInstance with add/remove/shuffle/draw methods. PlayerState: deck, hand, active, bench, prizes, discard zones plus turn state flags. GameState: game_id, rules, card_registry, players dict, turn tracking, winner.",
|
||||
"estimatedHours": 3,
|
||||
"notes": "GameState.card_registry holds all CardDefinitions used in this game. Zone operations need RandomProvider for shuffle."
|
||||
},
|
||||
{
|
||||
"id": "TEST-006",
|
||||
"name": "Create game state model tests",
|
||||
"description": "Test Zone operations, PlayerState initialization and turn resets, GameState properties and player access",
|
||||
"category": "high",
|
||||
"priority": 13,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-003"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_models/test_game_state.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: Zone add/remove/shuffle/draw, PlayerState turn state resets, GameState current_player property, GameState is_first_turn logic",
|
||||
"estimatedHours": 2,
|
||||
"notes": "Use SeededRandom for deterministic shuffle tests"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-004",
|
||||
"name": "Create test fixtures (conftest.py)",
|
||||
"description": "Create shared pytest fixtures for sample cards, game states, and seeded RNG instances",
|
||||
"category": "high",
|
||||
"priority": 14,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-001", "HIGH-003", "CRIT-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/conftest.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Create fixtures: sample_pokemon_card, sample_trainer_card, sample_energy_card, sample_deck, empty_game_state, mid_game_state, seeded_rng",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Fixtures should be composable and reusable across all test modules"
|
||||
},
|
||||
{
|
||||
"id": "MED-001",
|
||||
"name": "Create EffectContext and base types",
|
||||
"description": "Define the context object passed to effect handlers and the EffectResult return type",
|
||||
"category": "medium",
|
||||
"priority": 15,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-003"],
|
||||
"files": [
|
||||
{"path": "app/core/effects/base.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "EffectContext: game state, source player/card, target player/card, params dict, rng provider. EffectResult: success bool, message, state changes list for logging.",
|
||||
"estimatedHours": 1,
|
||||
"notes": "EffectContext should provide helper methods for common operations (get card by id, get opponent, etc.)"
|
||||
},
|
||||
{
|
||||
"id": "MED-002",
|
||||
"name": "Create effect handler registry",
|
||||
"description": "Implement the effect handler registry with decorator for registering handlers and lookup function for resolving effects",
|
||||
"category": "medium",
|
||||
"priority": 16,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["MED-001"],
|
||||
"files": [
|
||||
{"path": "app/core/effects/registry.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Global EFFECT_REGISTRY dict. @effect_handler(name) decorator adds function to registry. resolve_effect(effect_id, context) looks up and calls handler.",
|
||||
"estimatedHours": 1,
|
||||
"notes": "Consider async handlers for effects that might need I/O in the future"
|
||||
},
|
||||
{
|
||||
"id": "TEST-007",
|
||||
"name": "Create effect registry tests",
|
||||
"description": "Test that effect handlers register correctly and resolve_effect calls the right handler",
|
||||
"category": "medium",
|
||||
"priority": 17,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["MED-002"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_effects/test_registry.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: decorator registers handler, resolve_effect calls correct handler, unknown effect_id handled gracefully",
|
||||
"estimatedHours": 1,
|
||||
"notes": "Use mock handlers for testing registration"
|
||||
},
|
||||
{
|
||||
"id": "MED-003",
|
||||
"name": "Create built-in effect handlers",
|
||||
"description": "Implement common effect handlers: deal_damage, heal, draw_cards, discard_cards, apply_status, remove_status, coin_flip, discard_energy, search_deck",
|
||||
"category": "medium",
|
||||
"priority": 18,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["MED-002"],
|
||||
"files": [
|
||||
{"path": "app/core/effects/handlers.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Each handler is an async function decorated with @effect_handler. Use context.params for effect-specific parameters. Return EffectResult with success/failure.",
|
||||
"estimatedHours": 3,
|
||||
"notes": "coin_flip handler should use context.rng for testability. Effects should mutate game state in place."
|
||||
},
|
||||
{
|
||||
"id": "TEST-008",
|
||||
"name": "Create effect handler tests",
|
||||
"description": "Test each built-in effect handler with various scenarios",
|
||||
"category": "medium",
|
||||
"priority": 19,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["MED-003", "HIGH-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_effects/test_handlers.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: deal_damage reduces HP, heal restores HP up to max, draw_cards moves from deck to hand, apply_status adds condition, coin_flip with seeded RNG",
|
||||
"estimatedHours": 2,
|
||||
"notes": "Use seeded RNG fixtures for deterministic coin flip tests"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-005",
|
||||
"name": "Create rules validator",
|
||||
"description": "Implement config-driven action validation: check turn, phase, card ownership, action legality based on RulesConfig",
|
||||
"category": "high",
|
||||
"priority": 20,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-002", "HIGH-003", "CRIT-003"],
|
||||
"files": [
|
||||
{"path": "app/core/rules_validator.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "ValidationResult model with valid bool and reason string. validate_action(game, player_id, action) checks: is it player's turn, is phase correct, does player have the card, is action legal per rules. Separate validator functions per action type.",
|
||||
"estimatedHours": 4,
|
||||
"notes": "Most complex validation module. Must check all rule configurations (energy attachments per turn, supporter limit, bench size, etc.)"
|
||||
},
|
||||
{
|
||||
"id": "TEST-009",
|
||||
"name": "Create rules validator tests",
|
||||
"description": "Test action validation for each action type with valid and invalid scenarios",
|
||||
"category": "high",
|
||||
"priority": 21,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-005", "HIGH-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_rules_validator.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test per action type: valid action passes, wrong turn fails, wrong phase fails, card not owned fails, rule limit exceeded fails. Test with custom RulesConfig to verify config-driven behavior.",
|
||||
"estimatedHours": 3,
|
||||
"notes": "Critical tests - security depends on proper validation"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-006",
|
||||
"name": "Create win conditions checker",
|
||||
"description": "Implement config-driven win condition checking: all prizes taken, no Pokemon in play, cannot draw",
|
||||
"category": "high",
|
||||
"priority": 22,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-003", "CRIT-003"],
|
||||
"files": [
|
||||
{"path": "app/core/win_conditions.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "WinResult model with winner player_id and reason string. check_win_conditions(game) checks each enabled condition from rules config and returns WinResult if any are met.",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Check each condition independently based on game.rules.win_conditions flags"
|
||||
},
|
||||
{
|
||||
"id": "TEST-010",
|
||||
"name": "Create win conditions tests",
|
||||
"description": "Test each win condition triggers correctly and respects config flags",
|
||||
"category": "high",
|
||||
"priority": 23,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-006", "HIGH-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_win_conditions.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: all prizes taken triggers win, last Pokemon knocked out triggers win, empty deck triggers win, disabled conditions don't trigger, custom prize count works",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Test with different RulesConfig to verify each condition can be disabled"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-007",
|
||||
"name": "Create turn manager",
|
||||
"description": "Implement the turn/phase state machine with valid transitions and turn start/end handling",
|
||||
"category": "high",
|
||||
"priority": 24,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-003", "CRIT-002"],
|
||||
"files": [
|
||||
{"path": "app/core/turn_manager.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "TurnManager class with advance_phase(), end_turn(), start_turn() methods. Enforce valid transitions (DRAW->MAIN->ATTACK->END). Handle between-turn effects (poison/burn damage). Reset per-turn flags on turn start.",
|
||||
"estimatedHours": 2.5,
|
||||
"notes": "State machine should be strict about valid transitions. Consider setup phase for game initialization."
|
||||
},
|
||||
{
|
||||
"id": "TEST-011",
|
||||
"name": "Create turn manager tests",
|
||||
"description": "Test phase transitions, turn switching, and per-turn state resets",
|
||||
"category": "high",
|
||||
"priority": 25,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-007", "HIGH-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_turn_manager.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: valid transitions work, invalid transitions rejected, turn switch alternates players, per-turn flags reset, between-turn effects applied",
|
||||
"estimatedHours": 2,
|
||||
"notes": "Test poison/burn damage application between turns"
|
||||
},
|
||||
{
|
||||
"id": "HIGH-008",
|
||||
"name": "Create visibility filter",
|
||||
"description": "Implement hidden information filtering to create client-safe game state views",
|
||||
"category": "high",
|
||||
"priority": 26,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-003"],
|
||||
"files": [
|
||||
{"path": "app/core/visibility.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "VisibleGameState model with filtered data. get_visible_state(game, player_id) returns: full own hand, own prize count, opponent hand COUNT only, opponent deck COUNT only, public battlefield/discard. Never expose deck order or opponent hand contents.",
|
||||
"estimatedHours": 2,
|
||||
"notes": "CRITICAL SECURITY: This prevents cheating. Must never leak hidden information."
|
||||
},
|
||||
{
|
||||
"id": "TEST-012",
|
||||
"name": "Create visibility filter tests",
|
||||
"description": "Test that hidden information is never leaked and public information is preserved",
|
||||
"category": "high",
|
||||
"priority": 27,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-008", "HIGH-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_visibility.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: opponent hand contents not visible, opponent deck order not visible, own hand fully visible, battlefield fully visible, prize counts visible but not contents",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Security-critical tests. Verify no hidden data leaks through any path."
|
||||
},
|
||||
{
|
||||
"id": "HIGH-009",
|
||||
"name": "Create main GameEngine orchestrator",
|
||||
"description": "Implement the main GameEngine class that orchestrates all components: game creation, action validation, action execution, win condition checking",
|
||||
"category": "high",
|
||||
"priority": 28,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-005", "HIGH-006", "HIGH-007", "HIGH-008", "MED-002"],
|
||||
"files": [
|
||||
{"path": "app/core/engine.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "GameEngine class with: __init__(rules, rng_provider), create_game(player_ids, decks, card_registry), validate_action(game, player_id, action), execute_action(game, player_id, action), check_win_conditions(game), get_visible_state(game, player_id). All methods async.",
|
||||
"estimatedHours": 4,
|
||||
"notes": "This is the main public API. Should be the only entry point for game operations."
|
||||
},
|
||||
{
|
||||
"id": "TEST-013",
|
||||
"name": "Create GameEngine integration tests",
|
||||
"description": "Test full game flow from creation through actions to win condition",
|
||||
"category": "high",
|
||||
"priority": 29,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-009", "HIGH-004"],
|
||||
"files": [
|
||||
{"path": "tests/core/test_engine.py", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Test: create game with two players, execute valid actions, reject invalid actions, detect win condition, full sample game playthrough",
|
||||
"estimatedHours": 3,
|
||||
"notes": "Integration tests should cover realistic game scenarios"
|
||||
},
|
||||
{
|
||||
"id": "MED-004",
|
||||
"name": "Create core module exports",
|
||||
"description": "Set up clean public API exports in __init__.py files",
|
||||
"category": "medium",
|
||||
"priority": 30,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-009"],
|
||||
"files": [
|
||||
{"path": "app/core/__init__.py", "issue": "Needs public API exports"},
|
||||
{"path": "app/core/models/__init__.py", "issue": "Needs model exports"},
|
||||
{"path": "app/core/effects/__init__.py", "issue": "Needs effect exports"}
|
||||
],
|
||||
"suggestedFix": "Export key classes: GameEngine, RulesConfig, GameState, CardDefinition, CardInstance, Action types, effect_handler decorator",
|
||||
"estimatedHours": 0.5,
|
||||
"notes": "Clean API surface for consumers of the core module"
|
||||
},
|
||||
{
|
||||
"id": "DOCS-001",
|
||||
"name": "Create core module CLAUDE.md",
|
||||
"description": "Document the game engine architecture, patterns, and usage guidelines for AI agents",
|
||||
"category": "low",
|
||||
"priority": 31,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-009"],
|
||||
"files": [
|
||||
{"path": "app/core/CLAUDE.md", "issue": "File does not exist"}
|
||||
],
|
||||
"suggestedFix": "Document: module structure, key classes, effect handler pattern, configuration system, testing approach, security considerations (hidden info)",
|
||||
"estimatedHours": 1,
|
||||
"notes": "Reference doc as specified in CLAUDE.md"
|
||||
},
|
||||
{
|
||||
"id": "LOW-001",
|
||||
"name": "Add comprehensive docstrings",
|
||||
"description": "Ensure all public classes and methods have detailed docstrings with examples",
|
||||
"category": "low",
|
||||
"priority": 32,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-009"],
|
||||
"files": [
|
||||
{"path": "app/core/", "issue": "Review all files for docstring coverage"}
|
||||
],
|
||||
"suggestedFix": "Add Google-style docstrings with Args, Returns, Raises, and Example sections",
|
||||
"estimatedHours": 2,
|
||||
"notes": "Good documentation enables better AI assistance and maintainability"
|
||||
}
|
||||
],
|
||||
"quickWins": [
|
||||
{
|
||||
"taskId": "CRIT-001",
|
||||
"estimatedMinutes": 30,
|
||||
"impact": "Unblocks all other development"
|
||||
},
|
||||
{
|
||||
"taskId": "CRIT-002",
|
||||
"estimatedMinutes": 60,
|
||||
"impact": "Foundation for all models"
|
||||
},
|
||||
{
|
||||
"taskId": "MED-004",
|
||||
"estimatedMinutes": 30,
|
||||
"impact": "Clean public API"
|
||||
}
|
||||
],
|
||||
"productionBlockers": [
|
||||
{
|
||||
"taskId": "HIGH-005",
|
||||
"reason": "Cannot trust client actions without server-side validation"
|
||||
},
|
||||
{
|
||||
"taskId": "HIGH-008",
|
||||
"reason": "Hidden information must never leak to clients"
|
||||
},
|
||||
{
|
||||
"taskId": "HIGH-009",
|
||||
"reason": "No gameplay possible without engine orchestrator"
|
||||
}
|
||||
],
|
||||
"weeklyRoadmap": {
|
||||
"week1": {
|
||||
"theme": "Foundation & Models",
|
||||
"tasks": ["CRIT-001", "CRIT-002", "TEST-001", "CRIT-003", "TEST-002", "CRIT-004", "TEST-003"],
|
||||
"estimatedHours": 8,
|
||||
"goals": ["Module structure complete", "Enums, config, and RNG working with tests"]
|
||||
},
|
||||
"week2": {
|
||||
"theme": "Core Models",
|
||||
"tasks": ["HIGH-001", "TEST-004", "HIGH-002", "TEST-005", "HIGH-003", "TEST-006", "HIGH-004"],
|
||||
"estimatedHours": 12,
|
||||
"goals": ["All data models complete", "Test fixtures established"]
|
||||
},
|
||||
"week3": {
|
||||
"theme": "Effects System",
|
||||
"tasks": ["MED-001", "MED-002", "TEST-007", "MED-003", "TEST-008"],
|
||||
"estimatedHours": 8,
|
||||
"goals": ["Effect handler system working", "Built-in effects implemented"]
|
||||
},
|
||||
"week4": {
|
||||
"theme": "Game Logic",
|
||||
"tasks": ["HIGH-005", "TEST-009", "HIGH-006", "TEST-010", "HIGH-007", "TEST-011"],
|
||||
"estimatedHours": 14,
|
||||
"goals": ["Validation working", "Win conditions working", "Turn management working"]
|
||||
},
|
||||
"week5": {
|
||||
"theme": "Engine & Polish",
|
||||
"tasks": ["HIGH-008", "TEST-012", "HIGH-009", "TEST-013", "MED-004", "DOCS-001", "LOW-001"],
|
||||
"estimatedHours": 14,
|
||||
"goals": ["GameEngine complete", "Full integration tested", "Documentation complete"]
|
||||
}
|
||||
},
|
||||
"testingStrategy": {
|
||||
"unitTests": "Each module has corresponding test file",
|
||||
"integrationTests": "test_engine.py covers full game flow",
|
||||
"fixtures": "conftest.py provides reusable sample data",
|
||||
"determinism": "SeededRandom enables reproducible random tests",
|
||||
"coverage": "Target 90%+ coverage on core module"
|
||||
},
|
||||
"securityChecklist": [
|
||||
{
|
||||
"item": "Deck order never sent to client",
|
||||
"module": "visibility.py",
|
||||
"verified": false
|
||||
},
|
||||
{
|
||||
"item": "Opponent hand contents never sent",
|
||||
"module": "visibility.py",
|
||||
"verified": false
|
||||
},
|
||||
{
|
||||
"item": "Prize card contents hidden until taken",
|
||||
"module": "visibility.py",
|
||||
"verified": false
|
||||
},
|
||||
{
|
||||
"item": "All actions validated server-side",
|
||||
"module": "rules_validator.py",
|
||||
"verified": false
|
||||
},
|
||||
{
|
||||
"item": "RNG unpredictable in production",
|
||||
"module": "rng.py",
|
||||
"verified": false
|
||||
}
|
||||
]
|
||||
}
|
||||
17
backend/app/core/__init__.py
Normal file
17
backend/app/core/__init__.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Mantimon TCG - Core Game Engine.
|
||||
|
||||
This module contains the core game engine for Mantimon TCG, a highly configurable
|
||||
Pokemon TCG-inspired card game. The engine supports both campaign mode with fixed
|
||||
rules and free play mode with user-configurable rules.
|
||||
|
||||
Key Components:
|
||||
- config: RulesConfig and sub-configs for all game rules
|
||||
- models: Data models for cards, game state, and actions
|
||||
- effects: Effect handler system for card abilities and attacks
|
||||
- engine: Main GameEngine orchestrator
|
||||
- turn_manager: Turn/phase state machine
|
||||
- rules_validator: Action legality checking
|
||||
- win_conditions: Win/loss detection
|
||||
- visibility: Hidden information filtering for clients
|
||||
- rng: Random number generation with testable seeded implementation
|
||||
"""
|
||||
305
backend/app/core/config.py
Normal file
305
backend/app/core/config.py
Normal file
@ -0,0 +1,305 @@
|
||||
"""Game rules configuration for Mantimon TCG.
|
||||
|
||||
This module defines the master configuration system for all game rules. The engine
|
||||
is highly configurable to support both campaign mode (with fixed rules) and free
|
||||
play mode (with user-adjustable rules).
|
||||
|
||||
Default values are based on the Mantimon TCG house rules documented in GAME_RULES.md,
|
||||
which use a Pokemon Pocket-inspired energy system with a 40-card main deck and
|
||||
separate 20-card energy deck.
|
||||
|
||||
Usage:
|
||||
# Use default rules
|
||||
rules = RulesConfig()
|
||||
|
||||
# Customize specific rules
|
||||
rules = RulesConfig(
|
||||
deck=DeckConfig(min_size=60, max_size=60),
|
||||
prizes=PrizeConfig(count=6),
|
||||
)
|
||||
|
||||
# Load from JSON
|
||||
rules = RulesConfig.model_validate_json(json_string)
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.models.enums import EnergyType, PokemonStage
|
||||
|
||||
|
||||
class DeckConfig(BaseModel):
|
||||
"""Configuration for deck building rules.
|
||||
|
||||
Attributes:
|
||||
min_size: Minimum number of cards in the main deck.
|
||||
max_size: Maximum number of cards in the main deck.
|
||||
exact_size_required: If True, deck must be exactly min_size cards.
|
||||
max_copies_per_card: Maximum copies of any single card (by name).
|
||||
max_copies_basic_energy: Max copies of basic energy in energy deck.
|
||||
None means unlimited.
|
||||
min_basic_pokemon: Minimum number of Basic Pokemon required.
|
||||
energy_deck_enabled: If True, use separate energy deck (Pokemon Pocket style).
|
||||
energy_deck_size: Size of the separate energy deck.
|
||||
"""
|
||||
|
||||
min_size: int = 40
|
||||
max_size: int = 40
|
||||
exact_size_required: bool = True
|
||||
max_copies_per_card: int = 4
|
||||
max_copies_basic_energy: int | None = None
|
||||
min_basic_pokemon: int = 1
|
||||
energy_deck_enabled: bool = True
|
||||
energy_deck_size: int = 20
|
||||
|
||||
|
||||
class BenchConfig(BaseModel):
|
||||
"""Configuration for bench rules.
|
||||
|
||||
Attributes:
|
||||
max_size: Maximum number of Pokemon on the bench.
|
||||
"""
|
||||
|
||||
max_size: int = 5
|
||||
|
||||
|
||||
class EnergyConfig(BaseModel):
|
||||
"""Configuration for energy attachment rules.
|
||||
|
||||
Attributes:
|
||||
attachments_per_turn: Number of energy cards that can be attached per turn.
|
||||
types_enabled: List of energy types available in this game.
|
||||
auto_flip_from_deck: If True, flip top card of energy deck at turn start
|
||||
(Pokemon Pocket style).
|
||||
"""
|
||||
|
||||
attachments_per_turn: int = 1
|
||||
types_enabled: list[EnergyType] = Field(default_factory=lambda: list(EnergyType))
|
||||
auto_flip_from_deck: bool = True
|
||||
|
||||
|
||||
class PrizeConfig(BaseModel):
|
||||
"""Configuration for prize/scoring rules.
|
||||
|
||||
In core Mantimon TCG rules, "prizes" are replaced with "points" - players
|
||||
score points instead of taking prize cards. This simplifies the game while
|
||||
maintaining the knockout scoring mechanic.
|
||||
|
||||
Attributes:
|
||||
count: Number of points needed to win (or prize cards if using classic rules).
|
||||
per_knockout_basic: Points scored for knocking out a basic Pokemon.
|
||||
per_knockout_stage_1: Points scored for knocking out a Stage 1 Pokemon.
|
||||
per_knockout_stage_2: Points scored for knocking out a Stage 2 Pokemon.
|
||||
per_knockout_ex: Points scored for knocking out an EX Pokemon.
|
||||
per_knockout_v: Points scored for knocking out a V Pokemon.
|
||||
per_knockout_vmax: Points scored for knocking out a VMAX Pokemon.
|
||||
per_knockout_gx: Points scored for knocking out a GX Pokemon.
|
||||
use_prize_cards: If True, use classic prize card mechanic instead of points.
|
||||
prize_selection_random: If True, prize cards are taken randomly (classic).
|
||||
If False, player chooses which prize to take.
|
||||
"""
|
||||
|
||||
count: int = 4
|
||||
per_knockout_basic: int = 1
|
||||
per_knockout_stage_1: int = 1
|
||||
per_knockout_stage_2: int = 1
|
||||
per_knockout_ex: int = 2
|
||||
per_knockout_v: int = 2
|
||||
per_knockout_vmax: int = 3
|
||||
per_knockout_gx: int = 2
|
||||
use_prize_cards: bool = False
|
||||
prize_selection_random: bool = True
|
||||
|
||||
def points_for_knockout(self, stage: PokemonStage) -> int:
|
||||
"""Get the number of points scored for knocking out a Pokemon of the given stage.
|
||||
|
||||
Args:
|
||||
stage: The PokemonStage of the knocked out Pokemon.
|
||||
|
||||
Returns:
|
||||
Number of points to score.
|
||||
"""
|
||||
stage_map = {
|
||||
PokemonStage.BASIC: self.per_knockout_basic,
|
||||
PokemonStage.STAGE_1: self.per_knockout_stage_1,
|
||||
PokemonStage.STAGE_2: self.per_knockout_stage_2,
|
||||
PokemonStage.EX: self.per_knockout_ex,
|
||||
PokemonStage.V: self.per_knockout_v,
|
||||
PokemonStage.VMAX: self.per_knockout_vmax,
|
||||
PokemonStage.GX: self.per_knockout_gx,
|
||||
}
|
||||
return stage_map.get(stage, self.per_knockout_basic)
|
||||
|
||||
|
||||
class FirstTurnConfig(BaseModel):
|
||||
"""Configuration for first turn restrictions.
|
||||
|
||||
These rules apply only to the very first turn of the game (turn 1 for player 1).
|
||||
|
||||
Attributes:
|
||||
can_draw: Whether the first player draws a card on turn 1.
|
||||
can_attack: Whether the first player can attack on turn 1.
|
||||
can_play_supporter: Whether the first player can play Supporter cards on turn 1.
|
||||
can_attach_energy: Whether the first player can attach energy on turn 1.
|
||||
can_evolve: Whether the first player can evolve Pokemon on turn 1.
|
||||
"""
|
||||
|
||||
can_draw: bool = True
|
||||
can_attack: bool = True
|
||||
can_play_supporter: bool = True
|
||||
can_attach_energy: bool = False
|
||||
can_evolve: bool = False
|
||||
|
||||
|
||||
class WinConditionsConfig(BaseModel):
|
||||
"""Configuration for win/loss conditions.
|
||||
|
||||
Each condition can be enabled or disabled independently. A player wins
|
||||
when any enabled win condition is met.
|
||||
|
||||
Attributes:
|
||||
all_prizes_taken: Win when a player scores the required number of points.
|
||||
no_pokemon_in_play: Win when opponent has no Pokemon in play.
|
||||
cannot_draw: Win when opponent cannot draw a card at turn start.
|
||||
turn_limit_enabled: Enable maximum turn count (useful for AI matches).
|
||||
turn_limit: Maximum number of turns before game ends. Each player's
|
||||
turn counts as one turn (so 30 = 15 turns per player).
|
||||
turn_timer_enabled: Enable per-turn time limits (multiplayer).
|
||||
turn_timer_seconds: Seconds per turn before timeout (default 90).
|
||||
game_timer_enabled: Enable total game time limit (multiplayer).
|
||||
game_timer_minutes: Total game time in minutes.
|
||||
"""
|
||||
|
||||
all_prizes_taken: bool = True
|
||||
no_pokemon_in_play: bool = True
|
||||
cannot_draw: bool = True
|
||||
turn_limit_enabled: bool = True
|
||||
turn_limit: int = 30
|
||||
turn_timer_enabled: bool = False
|
||||
turn_timer_seconds: int = 90
|
||||
game_timer_enabled: bool = False
|
||||
game_timer_minutes: int = 30
|
||||
|
||||
|
||||
class StatusConfig(BaseModel):
|
||||
"""Configuration for status condition effects.
|
||||
|
||||
Defines the damage values and removal mechanics for each status condition.
|
||||
|
||||
Attributes:
|
||||
poison_damage: Damage dealt by Poison between turns.
|
||||
burn_damage: Damage dealt by Burn between turns.
|
||||
burn_flip_to_remove: If True, flip coin between turns; heads removes Burn.
|
||||
sleep_flip_to_wake: If True, flip coin between turns; heads removes Sleep.
|
||||
confusion_self_damage: Damage dealt to self on failed confusion flip.
|
||||
"""
|
||||
|
||||
poison_damage: int = 10
|
||||
burn_damage: int = 20
|
||||
burn_flip_to_remove: bool = True
|
||||
sleep_flip_to_wake: bool = True
|
||||
confusion_self_damage: int = 30
|
||||
|
||||
|
||||
class TrainerConfig(BaseModel):
|
||||
"""Configuration for Trainer card rules.
|
||||
|
||||
Attributes:
|
||||
supporters_per_turn: Maximum Supporter cards playable per turn.
|
||||
stadiums_per_turn: Maximum Stadium cards playable per turn.
|
||||
items_per_turn: Maximum Item cards per turn. None means unlimited.
|
||||
tools_per_pokemon: Maximum Tool cards attachable to one Pokemon.
|
||||
"""
|
||||
|
||||
supporters_per_turn: int = 1
|
||||
stadiums_per_turn: int = 1
|
||||
items_per_turn: int | None = None
|
||||
tools_per_pokemon: int = 1
|
||||
|
||||
|
||||
class EvolutionConfig(BaseModel):
|
||||
"""Configuration for evolution rules.
|
||||
|
||||
Attributes:
|
||||
same_turn_as_played: Can evolve a Pokemon the same turn it was played.
|
||||
same_turn_as_evolution: Can evolve a Pokemon the same turn it evolved.
|
||||
first_turn_of_game: Can evolve on the very first turn of the game.
|
||||
"""
|
||||
|
||||
same_turn_as_played: bool = False
|
||||
same_turn_as_evolution: bool = False
|
||||
first_turn_of_game: bool = False
|
||||
|
||||
|
||||
class RetreatConfig(BaseModel):
|
||||
"""Configuration for retreat rules.
|
||||
|
||||
Attributes:
|
||||
retreats_per_turn: Maximum number of retreats allowed per turn.
|
||||
free_retreat_cost: If True, retreating doesn't require discarding energy.
|
||||
"""
|
||||
|
||||
retreats_per_turn: int = 1
|
||||
free_retreat_cost: bool = False
|
||||
|
||||
|
||||
class RulesConfig(BaseModel):
|
||||
"""Master configuration for all game rules.
|
||||
|
||||
This is the top-level configuration object that contains all rule settings.
|
||||
Default values are based on Mantimon TCG house rules (Pokemon Pocket-inspired
|
||||
with 40-card decks, separate energy deck, and 4 points to win).
|
||||
|
||||
For standard Pokemon TCG rules, override with:
|
||||
RulesConfig(
|
||||
deck=DeckConfig(min_size=60, max_size=60, energy_deck_enabled=False),
|
||||
prizes=PrizeConfig(count=6, use_prize_cards=True),
|
||||
first_turn=FirstTurnConfig(can_attack=False, can_attach_energy=True),
|
||||
)
|
||||
|
||||
Attributes:
|
||||
deck: Deck building configuration.
|
||||
bench: Bench configuration.
|
||||
energy: Energy attachment configuration.
|
||||
prizes: Prize/scoring configuration.
|
||||
first_turn: First turn restrictions.
|
||||
win_conditions: Win/loss condition configuration.
|
||||
status: Status condition effect configuration.
|
||||
trainer: Trainer card rule configuration.
|
||||
evolution: Evolution rule configuration.
|
||||
retreat: Retreat rule configuration.
|
||||
"""
|
||||
|
||||
deck: DeckConfig = Field(default_factory=DeckConfig)
|
||||
bench: BenchConfig = Field(default_factory=BenchConfig)
|
||||
energy: EnergyConfig = Field(default_factory=EnergyConfig)
|
||||
prizes: PrizeConfig = Field(default_factory=PrizeConfig)
|
||||
first_turn: FirstTurnConfig = Field(default_factory=FirstTurnConfig)
|
||||
win_conditions: WinConditionsConfig = Field(default_factory=WinConditionsConfig)
|
||||
status: StatusConfig = Field(default_factory=StatusConfig)
|
||||
trainer: TrainerConfig = Field(default_factory=TrainerConfig)
|
||||
evolution: EvolutionConfig = Field(default_factory=EvolutionConfig)
|
||||
retreat: RetreatConfig = Field(default_factory=RetreatConfig)
|
||||
|
||||
@classmethod
|
||||
def standard_pokemon_tcg(cls) -> "RulesConfig":
|
||||
"""Create a configuration approximating standard Pokemon TCG rules.
|
||||
|
||||
Returns:
|
||||
RulesConfig with settings closer to official Pokemon TCG.
|
||||
"""
|
||||
return cls(
|
||||
deck=DeckConfig(
|
||||
min_size=60,
|
||||
max_size=60,
|
||||
energy_deck_enabled=False,
|
||||
),
|
||||
prizes=PrizeConfig(
|
||||
count=6,
|
||||
use_prize_cards=True,
|
||||
),
|
||||
first_turn=FirstTurnConfig(
|
||||
can_attack=False,
|
||||
can_play_supporter=False,
|
||||
can_attach_energy=True,
|
||||
),
|
||||
)
|
||||
11
backend/app/core/effects/__init__.py
Normal file
11
backend/app/core/effects/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Effect handler system for Mantimon TCG.
|
||||
|
||||
This module implements a data-driven effect system where card effects are
|
||||
defined by effect IDs that map to handler functions. This allows cards to
|
||||
be defined in JSON/database while effect logic lives in Python.
|
||||
|
||||
Key Components:
|
||||
- base: EffectContext and EffectResult types
|
||||
- registry: Effect handler registration and lookup
|
||||
- handlers: Built-in effect handlers (deal_damage, heal, etc.)
|
||||
"""
|
||||
8
backend/app/core/models/__init__.py
Normal file
8
backend/app/core/models/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""Core data models for the Mantimon TCG game engine.
|
||||
|
||||
This module contains all Pydantic models used throughout the game engine:
|
||||
- enums: Enumeration types (CardType, EnergyType, TurnPhase, etc.)
|
||||
- card: CardDefinition (template) and CardInstance (in-game state)
|
||||
- actions: Player action types as a discriminated union
|
||||
- game_state: GameState, PlayerState, and Zone models
|
||||
"""
|
||||
168
backend/app/core/models/enums.py
Normal file
168
backend/app/core/models/enums.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""Enumeration types for the Mantimon TCG game engine.
|
||||
|
||||
This module defines all enum types used throughout the game engine. We use StrEnum
|
||||
for JSON serialization compatibility - enum values serialize as strings directly.
|
||||
|
||||
Note on extensibility: While these enums define the standard types, the game engine
|
||||
is designed to be configurable. Custom energy types or card types could be added
|
||||
via configuration for free play mode.
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class CardType(StrEnum):
|
||||
"""The primary type of a card.
|
||||
|
||||
Every card in the game belongs to exactly one of these types, which determines
|
||||
how it can be played and what rules apply to it.
|
||||
"""
|
||||
|
||||
POKEMON = "pokemon"
|
||||
TRAINER = "trainer"
|
||||
ENERGY = "energy"
|
||||
|
||||
|
||||
class PokemonStage(StrEnum):
|
||||
"""The evolution stage of a Pokemon card.
|
||||
|
||||
Determines how the Pokemon can be played:
|
||||
- BASIC: Can be played directly from hand to bench
|
||||
- STAGE_1: Must evolve from a Basic Pokemon
|
||||
- STAGE_2: Must evolve from a Stage 1 Pokemon
|
||||
- V: Special basic Pokemon worth 2 knockout points
|
||||
- VMAX: Evolves from a V Pokemon, worth 3 knockout points
|
||||
- EX: Can be basic, stage 1, or stage 2; worth 2 knockout points
|
||||
- GX: Similar to EX, worth 2 knockout points
|
||||
"""
|
||||
|
||||
BASIC = "basic"
|
||||
STAGE_1 = "stage_1"
|
||||
STAGE_2 = "stage_2"
|
||||
V = "v"
|
||||
VMAX = "vmax"
|
||||
EX = "ex"
|
||||
GX = "gx"
|
||||
|
||||
|
||||
class EnergyType(StrEnum):
|
||||
"""Energy types available in the game.
|
||||
|
||||
Based on modern Pokemon TCG with 10 types. Colorless is special - any energy
|
||||
can satisfy colorless requirements.
|
||||
|
||||
Note: The engine supports all types, but specific games can restrict which
|
||||
types are enabled via RulesConfig.
|
||||
"""
|
||||
|
||||
COLORLESS = "colorless"
|
||||
DARKNESS = "darkness"
|
||||
DRAGON = "dragon"
|
||||
FIGHTING = "fighting"
|
||||
FIRE = "fire"
|
||||
GRASS = "grass"
|
||||
LIGHTNING = "lightning"
|
||||
METAL = "metal"
|
||||
PSYCHIC = "psychic"
|
||||
WATER = "water"
|
||||
|
||||
|
||||
class TrainerType(StrEnum):
|
||||
"""Subtypes of Trainer cards.
|
||||
|
||||
Each subtype has different rules for how many can be played per turn:
|
||||
- ITEM: Unlimited per turn
|
||||
- SUPPORTER: One per turn
|
||||
- STADIUM: One per turn, stays in play
|
||||
- TOOL: Attached to Pokemon, unlimited per turn
|
||||
"""
|
||||
|
||||
ITEM = "item"
|
||||
SUPPORTER = "supporter"
|
||||
STADIUM = "stadium"
|
||||
TOOL = "tool"
|
||||
|
||||
|
||||
class TurnPhase(StrEnum):
|
||||
"""Phases within a player's turn.
|
||||
|
||||
Turn structure:
|
||||
1. SETUP: Initial game setup (draw starting hand, place basics, set prizes)
|
||||
2. DRAW: Draw a card from deck
|
||||
3. MAIN: Play cards, attach energy, evolve, use abilities, retreat
|
||||
4. ATTACK: Declare and resolve an attack (optional)
|
||||
5. END: Apply end-of-turn effects, check knockouts, score points
|
||||
|
||||
Valid transitions:
|
||||
- SETUP -> DRAW (game start, first player's turn)
|
||||
- DRAW -> MAIN
|
||||
- MAIN -> ATTACK or END (can skip attack)
|
||||
- ATTACK -> END
|
||||
- END -> DRAW (next player's turn)
|
||||
"""
|
||||
|
||||
SETUP = "setup"
|
||||
DRAW = "draw"
|
||||
MAIN = "main"
|
||||
ATTACK = "attack"
|
||||
END = "end"
|
||||
|
||||
|
||||
class StatusCondition(StrEnum):
|
||||
"""Status conditions that can affect Pokemon in play.
|
||||
|
||||
Status conditions have specific effects and removal conditions:
|
||||
- POISONED: 10 damage between turns; removed by evolution, retreat, or card effect
|
||||
- BURNED: 20 damage between turns + flip to remove; removed on heads
|
||||
- ASLEEP: Cannot attack or retreat; removed on heads flip; overrides PARALYZED/CONFUSED
|
||||
- PARALYZED: Cannot attack or retreat for 1 turn; removed at end of next turn;
|
||||
overrides ASLEEP/CONFUSED
|
||||
- CONFUSED: Flip to attack, 30 self-damage on tails; removed by evolution/retreat;
|
||||
overrides ASLEEP/PARALYZED
|
||||
|
||||
Note: POISONED and BURNED stack with other conditions. ASLEEP, PARALYZED, and
|
||||
CONFUSED override each other (only one can be active at a time).
|
||||
"""
|
||||
|
||||
POISONED = "poisoned"
|
||||
BURNED = "burned"
|
||||
ASLEEP = "asleep"
|
||||
PARALYZED = "paralyzed"
|
||||
CONFUSED = "confused"
|
||||
|
||||
|
||||
class ActionType(StrEnum):
|
||||
"""Types of actions a player can take during their turn.
|
||||
|
||||
Each action type has specific validation rules and can only be performed
|
||||
during certain phases. See rules_validator.py for details.
|
||||
"""
|
||||
|
||||
PLAY_POKEMON = "play_pokemon"
|
||||
EVOLVE = "evolve"
|
||||
ATTACH_ENERGY = "attach_energy"
|
||||
PLAY_TRAINER = "play_trainer"
|
||||
USE_ABILITY = "use_ability"
|
||||
ATTACK = "attack"
|
||||
RETREAT = "retreat"
|
||||
PASS = "pass"
|
||||
|
||||
|
||||
class GameEndReason(StrEnum):
|
||||
"""Reasons why a game ended.
|
||||
|
||||
Used in GameState.end_reason to indicate how the game concluded:
|
||||
- PRIZES_TAKEN: A player took all required prize points
|
||||
- NO_POKEMON: A player has no Pokemon in play (active or bench)
|
||||
- DECK_EMPTY: A player cannot draw a card at the start of their turn
|
||||
- RESIGNATION: A player resigned from the match
|
||||
- TIMEOUT: A player ran out of time (multiplayer only)
|
||||
- DRAW: The game ended in a draw (tie on points at timer expiration)
|
||||
"""
|
||||
|
||||
PRIZES_TAKEN = "prizes_taken"
|
||||
NO_POKEMON = "no_pokemon"
|
||||
DECK_EMPTY = "deck_empty"
|
||||
RESIGNATION = "resignation"
|
||||
TIMEOUT = "timeout"
|
||||
DRAW = "draw"
|
||||
241
backend/app/core/rng.py
Normal file
241
backend/app/core/rng.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""Random number generation for the Mantimon TCG game engine.
|
||||
|
||||
This module provides a RandomProvider protocol with two implementations:
|
||||
- SeededRandom: Deterministic RNG for testing and replays
|
||||
- SecureRandom: Cryptographically secure RNG for production PvP
|
||||
|
||||
The RandomProvider abstraction allows the game engine to be tested with
|
||||
predictable random outcomes while using secure randomness in production.
|
||||
|
||||
Usage:
|
||||
# In tests - predictable outcomes
|
||||
rng = SeededRandom(seed=42)
|
||||
result = rng.coin_flip() # Always the same with same seed
|
||||
|
||||
# In production - secure randomness
|
||||
rng = SecureRandom()
|
||||
result = rng.coin_flip() # Unpredictable
|
||||
|
||||
# In game engine
|
||||
class GameEngine:
|
||||
def __init__(self, rng: RandomProvider | None = None):
|
||||
self.rng = rng or SecureRandom()
|
||||
"""
|
||||
|
||||
import random
|
||||
import secrets
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class RandomProvider(Protocol):
|
||||
"""Protocol for random number generation in the game engine.
|
||||
|
||||
This protocol defines the interface for all random operations needed
|
||||
by the game engine. Implementations can be deterministic (for testing)
|
||||
or cryptographically secure (for production).
|
||||
|
||||
All methods should be treated as if they have side effects (advancing
|
||||
the RNG state), even if the underlying implementation is deterministic.
|
||||
"""
|
||||
|
||||
def random(self) -> float:
|
||||
"""Return a random float in the range [0.0, 1.0).
|
||||
|
||||
Returns:
|
||||
A random float between 0.0 (inclusive) and 1.0 (exclusive).
|
||||
"""
|
||||
...
|
||||
|
||||
def randint(self, a: int, b: int) -> int:
|
||||
"""Return a random integer N such that a <= N <= b.
|
||||
|
||||
Args:
|
||||
a: Lower bound (inclusive).
|
||||
b: Upper bound (inclusive).
|
||||
|
||||
Returns:
|
||||
A random integer between a and b, inclusive.
|
||||
"""
|
||||
...
|
||||
|
||||
def choice(self, seq: Sequence[T]) -> T:
|
||||
"""Return a random element from a non-empty sequence.
|
||||
|
||||
Args:
|
||||
seq: A non-empty sequence to choose from.
|
||||
|
||||
Returns:
|
||||
A randomly selected element from the sequence.
|
||||
|
||||
Raises:
|
||||
IndexError: If the sequence is empty.
|
||||
"""
|
||||
...
|
||||
|
||||
def shuffle(self, seq: MutableSequence[T]) -> None:
|
||||
"""Shuffle a mutable sequence in place.
|
||||
|
||||
Args:
|
||||
seq: A mutable sequence to shuffle. Modified in place.
|
||||
"""
|
||||
...
|
||||
|
||||
def coin_flip(self) -> bool:
|
||||
"""Simulate a fair coin flip.
|
||||
|
||||
Returns:
|
||||
True for heads, False for tails.
|
||||
"""
|
||||
...
|
||||
|
||||
def sample(self, population: Sequence[T], k: int) -> list[T]:
|
||||
"""Return k unique random elements from population.
|
||||
|
||||
Args:
|
||||
population: Sequence to sample from.
|
||||
k: Number of elements to select.
|
||||
|
||||
Returns:
|
||||
A list of k unique elements from population.
|
||||
|
||||
Raises:
|
||||
ValueError: If k is larger than the population.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class SeededRandom:
|
||||
"""Deterministic random number generator for testing and replays.
|
||||
|
||||
Uses Python's random.Random with a seed for reproducible sequences.
|
||||
Given the same seed, the sequence of random values will always be identical.
|
||||
|
||||
Example:
|
||||
rng1 = SeededRandom(seed=12345)
|
||||
rng2 = SeededRandom(seed=12345)
|
||||
|
||||
# These will always be equal
|
||||
assert rng1.randint(1, 100) == rng2.randint(1, 100)
|
||||
assert rng1.coin_flip() == rng2.coin_flip()
|
||||
"""
|
||||
|
||||
def __init__(self, seed: int | None = None) -> None:
|
||||
"""Initialize with an optional seed.
|
||||
|
||||
Args:
|
||||
seed: Integer seed for the RNG. If None, uses a random seed
|
||||
(making this instance non-deterministic).
|
||||
"""
|
||||
self._rng = random.Random(seed)
|
||||
self._seed = seed
|
||||
|
||||
@property
|
||||
def seed(self) -> int | None:
|
||||
"""The seed used to initialize this RNG, if any."""
|
||||
return self._seed
|
||||
|
||||
def random(self) -> float:
|
||||
"""Return a random float in the range [0.0, 1.0)."""
|
||||
return self._rng.random()
|
||||
|
||||
def randint(self, a: int, b: int) -> int:
|
||||
"""Return a random integer N such that a <= N <= b."""
|
||||
return self._rng.randint(a, b)
|
||||
|
||||
def choice(self, seq: Sequence[T]) -> T:
|
||||
"""Return a random element from a non-empty sequence."""
|
||||
return self._rng.choice(seq)
|
||||
|
||||
def shuffle(self, seq: MutableSequence[T]) -> None:
|
||||
"""Shuffle a mutable sequence in place."""
|
||||
self._rng.shuffle(seq)
|
||||
|
||||
def coin_flip(self) -> bool:
|
||||
"""Simulate a fair coin flip. True = heads, False = tails."""
|
||||
return self._rng.random() < 0.5
|
||||
|
||||
def sample(self, population: Sequence[T], k: int) -> list[T]:
|
||||
"""Return k unique random elements from population."""
|
||||
return self._rng.sample(list(population), k)
|
||||
|
||||
|
||||
class SecureRandom:
|
||||
"""Cryptographically secure random number generator for production.
|
||||
|
||||
Uses Python's secrets module for unpredictable randomness. This should
|
||||
be used for all PvP games where predictability would be a security issue.
|
||||
|
||||
Note: This implementation cannot be seeded and is not reproducible,
|
||||
which is the intended behavior for production use.
|
||||
"""
|
||||
|
||||
def random(self) -> float:
|
||||
"""Return a random float in the range [0.0, 1.0)."""
|
||||
# secrets.randbelow returns [0, n), so we divide to get [0.0, 1.0)
|
||||
return secrets.randbelow(2**53) / (2**53)
|
||||
|
||||
def randint(self, a: int, b: int) -> int:
|
||||
"""Return a random integer N such that a <= N <= b."""
|
||||
if a > b:
|
||||
raise ValueError(f"a ({a}) must be <= b ({b})")
|
||||
return a + secrets.randbelow(b - a + 1)
|
||||
|
||||
def choice(self, seq: Sequence[T]) -> T:
|
||||
"""Return a random element from a non-empty sequence."""
|
||||
if not seq:
|
||||
raise IndexError("Cannot choose from empty sequence")
|
||||
return seq[secrets.randbelow(len(seq))]
|
||||
|
||||
def shuffle(self, seq: MutableSequence[T]) -> None:
|
||||
"""Shuffle a mutable sequence in place using Fisher-Yates algorithm."""
|
||||
n = len(seq)
|
||||
for i in range(n - 1, 0, -1):
|
||||
j = secrets.randbelow(i + 1)
|
||||
seq[i], seq[j] = seq[j], seq[i]
|
||||
|
||||
def coin_flip(self) -> bool:
|
||||
"""Simulate a fair coin flip. True = heads, False = tails."""
|
||||
return secrets.randbelow(2) == 0
|
||||
|
||||
def sample(self, population: Sequence[T], k: int) -> list[T]:
|
||||
"""Return k unique random elements from population."""
|
||||
if k > len(population):
|
||||
raise ValueError(f"Sample size {k} is larger than population size {len(population)}")
|
||||
if k < 0:
|
||||
raise ValueError(f"Sample size {k} must be non-negative")
|
||||
|
||||
# Use a set to track selected indices
|
||||
pool = list(population)
|
||||
result: list[T] = []
|
||||
for _ in range(k):
|
||||
idx = secrets.randbelow(len(pool))
|
||||
result.append(pool.pop(idx))
|
||||
return result
|
||||
|
||||
|
||||
def create_rng(seed: int | None = None, secure: bool = False) -> RandomProvider:
|
||||
"""Factory function to create an appropriate RandomProvider.
|
||||
|
||||
Args:
|
||||
seed: If provided, creates a SeededRandom with this seed.
|
||||
secure: If True and no seed provided, creates SecureRandom.
|
||||
If False and no seed provided, creates unseeded SeededRandom.
|
||||
|
||||
Returns:
|
||||
A RandomProvider instance.
|
||||
|
||||
Example:
|
||||
# For testing with reproducible results
|
||||
rng = create_rng(seed=42)
|
||||
|
||||
# For production PvP
|
||||
rng = create_rng(secure=True)
|
||||
"""
|
||||
if seed is not None:
|
||||
return SeededRandom(seed=seed)
|
||||
if secure:
|
||||
return SecureRandom()
|
||||
return SeededRandom()
|
||||
1
backend/tests/core/__init__.py
Normal file
1
backend/tests/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Mantimon TCG core game engine."""
|
||||
542
backend/tests/core/test_config.py
Normal file
542
backend/tests/core/test_config.py
Normal file
@ -0,0 +1,542 @@
|
||||
"""Tests for the RulesConfig and sub-configuration models.
|
||||
|
||||
These tests verify that:
|
||||
1. Default values match Mantimon TCG house rules from GAME_RULES.md
|
||||
2. Configuration can be customized via constructor or JSON
|
||||
3. Nested configs work correctly
|
||||
4. Helper methods function as expected
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from app.core.config import (
|
||||
BenchConfig,
|
||||
DeckConfig,
|
||||
EnergyConfig,
|
||||
EvolutionConfig,
|
||||
FirstTurnConfig,
|
||||
PrizeConfig,
|
||||
RetreatConfig,
|
||||
RulesConfig,
|
||||
StatusConfig,
|
||||
TrainerConfig,
|
||||
WinConditionsConfig,
|
||||
)
|
||||
from app.core.models.enums import EnergyType, PokemonStage
|
||||
|
||||
|
||||
class TestDeckConfig:
|
||||
"""Tests for DeckConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify DeckConfig defaults match Mantimon TCG house rules.
|
||||
|
||||
Per GAME_RULES.md: 40-card main deck, separate 20-card energy deck.
|
||||
"""
|
||||
config = DeckConfig()
|
||||
|
||||
assert config.min_size == 40
|
||||
assert config.max_size == 40
|
||||
assert config.exact_size_required is True
|
||||
assert config.max_copies_per_card == 4
|
||||
assert config.max_copies_basic_energy is None # Unlimited
|
||||
assert config.min_basic_pokemon == 1
|
||||
assert config.energy_deck_enabled is True
|
||||
assert config.energy_deck_size == 20
|
||||
|
||||
def test_custom_values(self) -> None:
|
||||
"""
|
||||
Verify DeckConfig can be customized.
|
||||
|
||||
This is important for free play mode where users can adjust rules.
|
||||
"""
|
||||
config = DeckConfig(
|
||||
min_size=60,
|
||||
max_size=60,
|
||||
energy_deck_enabled=False,
|
||||
)
|
||||
|
||||
assert config.min_size == 60
|
||||
assert config.max_size == 60
|
||||
assert config.energy_deck_enabled is False
|
||||
# Other values should still be defaults
|
||||
assert config.max_copies_per_card == 4
|
||||
|
||||
|
||||
class TestBenchConfig:
|
||||
"""Tests for BenchConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify BenchConfig defaults to standard 5 Pokemon bench.
|
||||
"""
|
||||
config = BenchConfig()
|
||||
assert config.max_size == 5
|
||||
|
||||
def test_custom_bench_size(self) -> None:
|
||||
"""
|
||||
Verify bench size can be customized for variant rules.
|
||||
"""
|
||||
config = BenchConfig(max_size=8)
|
||||
assert config.max_size == 8
|
||||
|
||||
|
||||
class TestEnergyConfig:
|
||||
"""Tests for EnergyConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify EnergyConfig defaults to Pokemon Pocket-style energy system.
|
||||
"""
|
||||
config = EnergyConfig()
|
||||
|
||||
assert config.attachments_per_turn == 1
|
||||
assert config.auto_flip_from_deck is True
|
||||
assert len(config.types_enabled) == 10
|
||||
assert EnergyType.FIRE in config.types_enabled
|
||||
assert EnergyType.COLORLESS in config.types_enabled
|
||||
|
||||
def test_all_energy_types_enabled_by_default(self) -> None:
|
||||
"""
|
||||
Verify all 10 energy types are enabled by default.
|
||||
"""
|
||||
config = EnergyConfig()
|
||||
expected_types = set(EnergyType)
|
||||
actual_types = set(config.types_enabled)
|
||||
assert actual_types == expected_types
|
||||
|
||||
def test_restrict_energy_types(self) -> None:
|
||||
"""
|
||||
Verify energy types can be restricted for themed games.
|
||||
"""
|
||||
config = EnergyConfig(
|
||||
types_enabled=[EnergyType.FIRE, EnergyType.WATER, EnergyType.COLORLESS]
|
||||
)
|
||||
assert len(config.types_enabled) == 3
|
||||
assert EnergyType.FIRE in config.types_enabled
|
||||
assert EnergyType.GRASS not in config.types_enabled
|
||||
|
||||
|
||||
class TestPrizeConfig:
|
||||
"""Tests for PrizeConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify PrizeConfig defaults match Mantimon TCG house rules.
|
||||
|
||||
Per GAME_RULES.md: 4 points to win, points instead of prize cards.
|
||||
"""
|
||||
config = PrizeConfig()
|
||||
|
||||
assert config.count == 4
|
||||
assert config.per_knockout_basic == 1
|
||||
assert config.per_knockout_ex == 2
|
||||
assert config.per_knockout_v == 2
|
||||
assert config.per_knockout_vmax == 3
|
||||
assert config.use_prize_cards is False
|
||||
|
||||
def test_points_for_knockout_basic(self) -> None:
|
||||
"""
|
||||
Verify points_for_knockout returns correct value for basic Pokemon.
|
||||
"""
|
||||
config = PrizeConfig()
|
||||
assert config.points_for_knockout(PokemonStage.BASIC) == 1
|
||||
|
||||
def test_points_for_knockout_ex(self) -> None:
|
||||
"""
|
||||
Verify points_for_knockout returns 2 for EX Pokemon.
|
||||
|
||||
EX Pokemon are worth 2 knockout points per GAME_RULES.md.
|
||||
"""
|
||||
config = PrizeConfig()
|
||||
assert config.points_for_knockout(PokemonStage.EX) == 2
|
||||
|
||||
def test_points_for_knockout_vmax(self) -> None:
|
||||
"""
|
||||
Verify points_for_knockout returns 3 for VMAX Pokemon.
|
||||
|
||||
VMAX are the highest-value Pokemon in the game.
|
||||
"""
|
||||
config = PrizeConfig()
|
||||
assert config.points_for_knockout(PokemonStage.VMAX) == 3
|
||||
|
||||
def test_points_for_knockout_all_stages(self) -> None:
|
||||
"""
|
||||
Verify points_for_knockout works for all Pokemon stages.
|
||||
"""
|
||||
config = PrizeConfig()
|
||||
|
||||
assert config.points_for_knockout(PokemonStage.BASIC) == 1
|
||||
assert config.points_for_knockout(PokemonStage.STAGE_1) == 1
|
||||
assert config.points_for_knockout(PokemonStage.STAGE_2) == 1
|
||||
assert config.points_for_knockout(PokemonStage.EX) == 2
|
||||
assert config.points_for_knockout(PokemonStage.V) == 2
|
||||
assert config.points_for_knockout(PokemonStage.VMAX) == 3
|
||||
assert config.points_for_knockout(PokemonStage.GX) == 2
|
||||
|
||||
def test_custom_knockout_points(self) -> None:
|
||||
"""
|
||||
Verify knockout point values can be customized.
|
||||
"""
|
||||
config = PrizeConfig(per_knockout_basic=2, per_knockout_ex=3)
|
||||
assert config.points_for_knockout(PokemonStage.BASIC) == 2
|
||||
assert config.points_for_knockout(PokemonStage.EX) == 3
|
||||
|
||||
|
||||
class TestFirstTurnConfig:
|
||||
"""Tests for FirstTurnConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify FirstTurnConfig defaults match Mantimon TCG house rules.
|
||||
|
||||
Per GAME_RULES.md:
|
||||
- First player CAN draw, attack, and play supporters on turn 1
|
||||
- First player CANNOT attach energy or evolve on turn 1
|
||||
"""
|
||||
config = FirstTurnConfig()
|
||||
|
||||
assert config.can_draw is True
|
||||
assert config.can_attack is True
|
||||
assert config.can_play_supporter is True
|
||||
assert config.can_attach_energy is False
|
||||
assert config.can_evolve is False
|
||||
|
||||
def test_standard_pokemon_tcg_first_turn(self) -> None:
|
||||
"""
|
||||
Verify configuration for standard Pokemon TCG first turn rules.
|
||||
|
||||
In standard rules, first player cannot attack but can attach energy.
|
||||
"""
|
||||
config = FirstTurnConfig(
|
||||
can_attack=False,
|
||||
can_play_supporter=False,
|
||||
can_attach_energy=True,
|
||||
)
|
||||
|
||||
assert config.can_attack is False
|
||||
assert config.can_play_supporter is False
|
||||
assert config.can_attach_energy is True
|
||||
|
||||
|
||||
class TestWinConditionsConfig:
|
||||
"""Tests for WinConditionsConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify WinConditionsConfig defaults enable all standard win conditions.
|
||||
"""
|
||||
config = WinConditionsConfig()
|
||||
|
||||
assert config.all_prizes_taken is True
|
||||
assert config.no_pokemon_in_play is True
|
||||
assert config.cannot_draw is True
|
||||
assert config.turn_limit_enabled is True
|
||||
assert config.turn_limit == 30
|
||||
assert config.turn_timer_enabled is False
|
||||
assert config.turn_timer_seconds == 90
|
||||
assert config.game_timer_enabled is False
|
||||
assert config.game_timer_minutes == 30
|
||||
|
||||
def test_turn_limit_config(self) -> None:
|
||||
"""
|
||||
Verify turn limit settings for AI/single-player matches.
|
||||
|
||||
Turn limits are useful when real-time timers don't make sense,
|
||||
such as playing against AI or in puzzle mode.
|
||||
"""
|
||||
config = WinConditionsConfig(
|
||||
turn_limit_enabled=True,
|
||||
turn_limit=20,
|
||||
)
|
||||
|
||||
assert config.turn_limit_enabled is True
|
||||
assert config.turn_limit == 20
|
||||
|
||||
def test_multiplayer_timer_config(self) -> None:
|
||||
"""
|
||||
Verify timer settings for multiplayer mode.
|
||||
"""
|
||||
config = WinConditionsConfig(
|
||||
turn_timer_enabled=True,
|
||||
turn_timer_seconds=120,
|
||||
game_timer_enabled=True,
|
||||
game_timer_minutes=45,
|
||||
)
|
||||
|
||||
assert config.turn_timer_enabled is True
|
||||
assert config.turn_timer_seconds == 120
|
||||
assert config.game_timer_enabled is True
|
||||
assert config.game_timer_minutes == 45
|
||||
|
||||
|
||||
class TestStatusConfig:
|
||||
"""Tests for StatusConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify StatusConfig defaults match GAME_RULES.md.
|
||||
|
||||
Per documentation:
|
||||
- Poison: 10 damage between turns
|
||||
- Burn: 20 damage between turns, flip to remove
|
||||
- Confusion: 30 self-damage on tails
|
||||
"""
|
||||
config = StatusConfig()
|
||||
|
||||
assert config.poison_damage == 10
|
||||
assert config.burn_damage == 20
|
||||
assert config.burn_flip_to_remove is True
|
||||
assert config.sleep_flip_to_wake is True
|
||||
assert config.confusion_self_damage == 30
|
||||
|
||||
|
||||
class TestTrainerConfig:
|
||||
"""Tests for TrainerConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify TrainerConfig defaults match standard rules.
|
||||
|
||||
Per GAME_RULES.md:
|
||||
- Items: Unlimited per turn
|
||||
- Supporters: One per turn
|
||||
- Stadiums: One per turn
|
||||
- Tools: One per Pokemon
|
||||
"""
|
||||
config = TrainerConfig()
|
||||
|
||||
assert config.supporters_per_turn == 1
|
||||
assert config.stadiums_per_turn == 1
|
||||
assert config.items_per_turn is None # Unlimited
|
||||
assert config.tools_per_pokemon == 1
|
||||
|
||||
|
||||
class TestEvolutionConfig:
|
||||
"""Tests for EvolutionConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify EvolutionConfig defaults to standard evolution rules.
|
||||
|
||||
By default, Pokemon cannot evolve:
|
||||
- The same turn they were played
|
||||
- The same turn they already evolved
|
||||
- On the first turn of the game
|
||||
"""
|
||||
config = EvolutionConfig()
|
||||
|
||||
assert config.same_turn_as_played is False
|
||||
assert config.same_turn_as_evolution is False
|
||||
assert config.first_turn_of_game is False
|
||||
|
||||
|
||||
class TestRetreatConfig:
|
||||
"""Tests for RetreatConfig."""
|
||||
|
||||
def test_default_values(self) -> None:
|
||||
"""
|
||||
Verify RetreatConfig defaults to standard retreat rules.
|
||||
"""
|
||||
config = RetreatConfig()
|
||||
|
||||
assert config.retreats_per_turn == 1
|
||||
assert config.free_retreat_cost is False
|
||||
|
||||
|
||||
class TestRulesConfig:
|
||||
"""Tests for the master RulesConfig."""
|
||||
|
||||
def test_default_instantiation(self) -> None:
|
||||
"""
|
||||
Verify RulesConfig can be instantiated with all defaults.
|
||||
|
||||
All nested configs should be created with their own defaults.
|
||||
"""
|
||||
rules = RulesConfig()
|
||||
|
||||
assert rules.deck.min_size == 40
|
||||
assert rules.bench.max_size == 5
|
||||
assert rules.energy.attachments_per_turn == 1
|
||||
assert rules.prizes.count == 4
|
||||
assert rules.first_turn.can_attack is True
|
||||
assert rules.win_conditions.all_prizes_taken is True
|
||||
assert rules.status.poison_damage == 10
|
||||
assert rules.trainer.supporters_per_turn == 1
|
||||
assert rules.evolution.same_turn_as_played is False
|
||||
assert rules.retreat.retreats_per_turn == 1
|
||||
|
||||
def test_partial_override(self) -> None:
|
||||
"""
|
||||
Verify RulesConfig allows partial overrides of nested configs.
|
||||
|
||||
Only the specified values should change; others remain default.
|
||||
"""
|
||||
rules = RulesConfig(
|
||||
deck=DeckConfig(min_size=60, max_size=60),
|
||||
prizes=PrizeConfig(count=6),
|
||||
)
|
||||
|
||||
# Overridden values
|
||||
assert rules.deck.min_size == 60
|
||||
assert rules.deck.max_size == 60
|
||||
assert rules.prizes.count == 6
|
||||
|
||||
# Default values still in place
|
||||
assert rules.deck.max_copies_per_card == 4
|
||||
assert rules.bench.max_size == 5
|
||||
assert rules.first_turn.can_attack is True
|
||||
|
||||
def test_standard_pokemon_tcg_preset(self) -> None:
|
||||
"""
|
||||
Verify standard_pokemon_tcg() returns configuration for official rules.
|
||||
|
||||
This preset should override Mantimon defaults to match standard Pokemon TCG.
|
||||
"""
|
||||
rules = RulesConfig.standard_pokemon_tcg()
|
||||
|
||||
# Standard rules: 60-card deck, no energy deck
|
||||
assert rules.deck.min_size == 60
|
||||
assert rules.deck.max_size == 60
|
||||
assert rules.deck.energy_deck_enabled is False
|
||||
|
||||
# Standard rules: 6 prizes with prize cards
|
||||
assert rules.prizes.count == 6
|
||||
assert rules.prizes.use_prize_cards is True
|
||||
|
||||
# Standard rules: first player cannot attack or play supporter
|
||||
assert rules.first_turn.can_attack is False
|
||||
assert rules.first_turn.can_play_supporter is False
|
||||
assert rules.first_turn.can_attach_energy is True
|
||||
|
||||
def test_json_round_trip(self) -> None:
|
||||
"""
|
||||
Verify RulesConfig serializes and deserializes correctly.
|
||||
|
||||
This is critical for:
|
||||
- Saving game configurations to database
|
||||
- Sending rules to clients
|
||||
- Loading preset rule configurations
|
||||
"""
|
||||
original = RulesConfig(
|
||||
deck=DeckConfig(min_size=50),
|
||||
prizes=PrizeConfig(count=5, per_knockout_ex=3),
|
||||
)
|
||||
|
||||
json_str = original.model_dump_json()
|
||||
restored = RulesConfig.model_validate_json(json_str)
|
||||
|
||||
assert restored.deck.min_size == 50
|
||||
assert restored.prizes.count == 5
|
||||
assert restored.prizes.per_knockout_ex == 3
|
||||
# Defaults should be preserved
|
||||
assert restored.bench.max_size == 5
|
||||
|
||||
def test_json_output_format(self) -> None:
|
||||
"""
|
||||
Verify JSON output uses expected field names for compatibility.
|
||||
|
||||
Field names in JSON should match the Python attribute names.
|
||||
"""
|
||||
rules = RulesConfig()
|
||||
data = rules.model_dump()
|
||||
|
||||
# Verify top-level keys
|
||||
assert "deck" in data
|
||||
assert "bench" in data
|
||||
assert "energy" in data
|
||||
assert "prizes" in data
|
||||
assert "first_turn" in data
|
||||
assert "win_conditions" in data
|
||||
assert "status" in data
|
||||
assert "trainer" in data
|
||||
assert "evolution" in data
|
||||
assert "retreat" in data
|
||||
|
||||
# Verify nested keys
|
||||
assert "min_size" in data["deck"]
|
||||
assert "max_size" in data["deck"]
|
||||
assert "attachments_per_turn" in data["energy"]
|
||||
|
||||
def test_nested_config_independence(self) -> None:
|
||||
"""
|
||||
Verify nested configs don't share state between RulesConfig instances.
|
||||
|
||||
Each RulesConfig should have its own copies of nested configs.
|
||||
"""
|
||||
rules1 = RulesConfig()
|
||||
rules2 = RulesConfig()
|
||||
|
||||
# Modify rules1's nested config
|
||||
rules1.deck.min_size = 100
|
||||
|
||||
# rules2 should be unaffected
|
||||
assert rules2.deck.min_size == 40
|
||||
|
||||
|
||||
class TestRulesConfigFromJson:
|
||||
"""Tests for loading RulesConfig from JSON strings."""
|
||||
|
||||
def test_load_minimal_json(self) -> None:
|
||||
"""
|
||||
Verify RulesConfig can be loaded from minimal JSON.
|
||||
|
||||
Missing fields should use defaults.
|
||||
"""
|
||||
json_str = '{"prizes": {"count": 8}}'
|
||||
rules = RulesConfig.model_validate_json(json_str)
|
||||
|
||||
assert rules.prizes.count == 8
|
||||
assert rules.deck.min_size == 40 # Default
|
||||
|
||||
def test_load_full_json(self) -> None:
|
||||
"""
|
||||
Verify RulesConfig can be loaded from comprehensive JSON.
|
||||
|
||||
This tests the format documented in GAME_RULES.md.
|
||||
"""
|
||||
config_dict = {
|
||||
"deck": {
|
||||
"min_size": 60,
|
||||
"max_size": 60,
|
||||
"max_copies_per_card": 4,
|
||||
"min_basic_pokemon": 1,
|
||||
"energy_deck_enabled": False,
|
||||
},
|
||||
"prizes": {
|
||||
"count": 6,
|
||||
"per_knockout_basic": 1,
|
||||
"per_knockout_ex": 2,
|
||||
"use_prize_cards": True,
|
||||
},
|
||||
"energy": {"attachments_per_turn": 1},
|
||||
"first_turn": {
|
||||
"can_draw": True,
|
||||
"can_attack": False,
|
||||
"can_play_supporter": False,
|
||||
},
|
||||
"win_conditions": {
|
||||
"all_prizes_taken": True,
|
||||
"no_pokemon_in_play": True,
|
||||
"cannot_draw": True,
|
||||
},
|
||||
}
|
||||
json_str = json.dumps(config_dict)
|
||||
rules = RulesConfig.model_validate_json(json_str)
|
||||
|
||||
assert rules.deck.min_size == 60
|
||||
assert rules.deck.energy_deck_enabled is False
|
||||
assert rules.prizes.count == 6
|
||||
assert rules.prizes.use_prize_cards is True
|
||||
assert rules.first_turn.can_attack is False
|
||||
|
||||
def test_empty_json_uses_all_defaults(self) -> None:
|
||||
"""
|
||||
Verify empty JSON object creates config with all defaults.
|
||||
"""
|
||||
rules = RulesConfig.model_validate_json("{}")
|
||||
|
||||
assert rules.deck.min_size == 40
|
||||
assert rules.prizes.count == 4
|
||||
assert rules.first_turn.can_attack is True
|
||||
1
backend/tests/core/test_effects/__init__.py
Normal file
1
backend/tests/core/test_effects/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the effect handler system."""
|
||||
1
backend/tests/core/test_models/__init__.py
Normal file
1
backend/tests/core/test_models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for core data models."""
|
||||
302
backend/tests/core/test_models/test_enums.py
Normal file
302
backend/tests/core/test_models/test_enums.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""Tests for the enumeration types in the game engine.
|
||||
|
||||
These tests verify that all enums:
|
||||
1. Have expected values that won't accidentally change
|
||||
2. Serialize correctly to JSON (as strings)
|
||||
3. Can be used in Pydantic models
|
||||
4. Support membership checks and iteration
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.models.enums import (
|
||||
ActionType,
|
||||
CardType,
|
||||
EnergyType,
|
||||
GameEndReason,
|
||||
PokemonStage,
|
||||
StatusCondition,
|
||||
TrainerType,
|
||||
TurnPhase,
|
||||
)
|
||||
|
||||
|
||||
class TestCardType:
|
||||
"""Tests for the CardType enum."""
|
||||
|
||||
def test_card_type_values(self) -> None:
|
||||
"""
|
||||
Verify CardType has exactly three types with expected string values.
|
||||
|
||||
The three card types are fundamental to the game and should never change.
|
||||
"""
|
||||
assert CardType.POKEMON == "pokemon"
|
||||
assert CardType.TRAINER == "trainer"
|
||||
assert CardType.ENERGY == "energy"
|
||||
assert len(CardType) == 3
|
||||
|
||||
def test_card_type_json_serialization(self) -> None:
|
||||
"""
|
||||
Verify CardType serializes to a plain string in JSON.
|
||||
|
||||
This is important for WebSocket messages and database storage.
|
||||
"""
|
||||
assert json.dumps(CardType.POKEMON) == '"pokemon"'
|
||||
|
||||
def test_card_type_in_pydantic_model(self) -> None:
|
||||
"""
|
||||
Verify CardType works correctly in Pydantic models.
|
||||
|
||||
Pydantic should accept both enum values and string values.
|
||||
"""
|
||||
|
||||
class CardModel(BaseModel):
|
||||
card_type: CardType
|
||||
|
||||
# Accept enum value
|
||||
model = CardModel(card_type=CardType.POKEMON)
|
||||
assert model.card_type == CardType.POKEMON
|
||||
|
||||
# Accept string value
|
||||
model = CardModel(card_type="trainer") # type: ignore[arg-type]
|
||||
assert model.card_type == CardType.TRAINER
|
||||
|
||||
# JSON round-trip
|
||||
json_str = model.model_dump_json()
|
||||
restored = CardModel.model_validate_json(json_str)
|
||||
assert restored.card_type == CardType.TRAINER
|
||||
|
||||
|
||||
class TestPokemonStage:
|
||||
"""Tests for the PokemonStage enum."""
|
||||
|
||||
def test_pokemon_stage_values(self) -> None:
|
||||
"""
|
||||
Verify PokemonStage has all expected evolution stages.
|
||||
|
||||
Includes both classic stages (basic, stage 1, stage 2) and modern
|
||||
variants (V, VMAX, EX, GX).
|
||||
"""
|
||||
assert PokemonStage.BASIC == "basic"
|
||||
assert PokemonStage.STAGE_1 == "stage_1"
|
||||
assert PokemonStage.STAGE_2 == "stage_2"
|
||||
assert PokemonStage.V == "v"
|
||||
assert PokemonStage.VMAX == "vmax"
|
||||
assert PokemonStage.EX == "ex"
|
||||
assert PokemonStage.GX == "gx"
|
||||
assert len(PokemonStage) == 7
|
||||
|
||||
def test_pokemon_stage_membership(self) -> None:
|
||||
"""
|
||||
Verify membership checks work for PokemonStage.
|
||||
|
||||
This is used to validate card definitions.
|
||||
"""
|
||||
assert "basic" in [s.value for s in PokemonStage]
|
||||
assert "invalid" not in [s.value for s in PokemonStage]
|
||||
|
||||
|
||||
class TestEnergyType:
|
||||
"""Tests for the EnergyType enum."""
|
||||
|
||||
def test_energy_type_values(self) -> None:
|
||||
"""
|
||||
Verify EnergyType has all 10 modern Pokemon TCG energy types.
|
||||
|
||||
The types should match the documented 10 types from GAME_RULES.md.
|
||||
"""
|
||||
expected_types = {
|
||||
"colorless",
|
||||
"darkness",
|
||||
"dragon",
|
||||
"fighting",
|
||||
"fire",
|
||||
"grass",
|
||||
"lightning",
|
||||
"metal",
|
||||
"psychic",
|
||||
"water",
|
||||
}
|
||||
actual_types = {e.value for e in EnergyType}
|
||||
assert actual_types == expected_types
|
||||
assert len(EnergyType) == 10
|
||||
|
||||
def test_colorless_is_special(self) -> None:
|
||||
"""
|
||||
Verify COLORLESS energy type exists.
|
||||
|
||||
Colorless has special meaning - any energy can satisfy colorless requirements.
|
||||
This test documents that assumption.
|
||||
"""
|
||||
assert EnergyType.COLORLESS == "colorless"
|
||||
assert EnergyType.COLORLESS in EnergyType
|
||||
|
||||
def test_energy_type_iteration(self) -> None:
|
||||
"""
|
||||
Verify EnergyType can be iterated for building default configs.
|
||||
|
||||
The engine uses list(EnergyType) to get all available types.
|
||||
"""
|
||||
all_types = list(EnergyType)
|
||||
assert len(all_types) == 10
|
||||
assert all(isinstance(t, EnergyType) for t in all_types)
|
||||
|
||||
|
||||
class TestTrainerType:
|
||||
"""Tests for the TrainerType enum."""
|
||||
|
||||
def test_trainer_type_values(self) -> None:
|
||||
"""
|
||||
Verify TrainerType has all four trainer subtypes.
|
||||
|
||||
These match the modern Pokemon TCG trainer card structure.
|
||||
"""
|
||||
assert TrainerType.ITEM == "item"
|
||||
assert TrainerType.SUPPORTER == "supporter"
|
||||
assert TrainerType.STADIUM == "stadium"
|
||||
assert TrainerType.TOOL == "tool"
|
||||
assert len(TrainerType) == 4
|
||||
|
||||
|
||||
class TestTurnPhase:
|
||||
"""Tests for the TurnPhase enum."""
|
||||
|
||||
def test_turn_phase_values(self) -> None:
|
||||
"""
|
||||
Verify TurnPhase has all five phases in the turn structure.
|
||||
|
||||
The phases follow the order: SETUP -> DRAW -> MAIN -> ATTACK -> END.
|
||||
"""
|
||||
assert TurnPhase.SETUP == "setup"
|
||||
assert TurnPhase.DRAW == "draw"
|
||||
assert TurnPhase.MAIN == "main"
|
||||
assert TurnPhase.ATTACK == "attack"
|
||||
assert TurnPhase.END == "end"
|
||||
assert len(TurnPhase) == 5
|
||||
|
||||
def test_turn_phase_order(self) -> None:
|
||||
"""
|
||||
Verify turn phases can be compared for ordering.
|
||||
|
||||
While we don't rely on enum ordering for logic, this documents
|
||||
the expected sequence.
|
||||
"""
|
||||
phases = list(TurnPhase)
|
||||
assert phases[0] == TurnPhase.SETUP
|
||||
assert phases[-1] == TurnPhase.END
|
||||
|
||||
|
||||
class TestStatusCondition:
|
||||
"""Tests for the StatusCondition enum."""
|
||||
|
||||
def test_status_condition_values(self) -> None:
|
||||
"""
|
||||
Verify StatusCondition has all five status conditions.
|
||||
|
||||
These match the status conditions documented in GAME_RULES.md.
|
||||
"""
|
||||
assert StatusCondition.POISONED == "poisoned"
|
||||
assert StatusCondition.BURNED == "burned"
|
||||
assert StatusCondition.ASLEEP == "asleep"
|
||||
assert StatusCondition.PARALYZED == "paralyzed"
|
||||
assert StatusCondition.CONFUSED == "confused"
|
||||
assert len(StatusCondition) == 5
|
||||
|
||||
def test_stacking_conditions(self) -> None:
|
||||
"""
|
||||
Document which conditions can stack with others.
|
||||
|
||||
POISONED and BURNED can stack with any condition.
|
||||
ASLEEP, PARALYZED, and CONFUSED override each other.
|
||||
"""
|
||||
stacking_conditions = {StatusCondition.POISONED, StatusCondition.BURNED}
|
||||
overriding_conditions = {
|
||||
StatusCondition.ASLEEP,
|
||||
StatusCondition.PARALYZED,
|
||||
StatusCondition.CONFUSED,
|
||||
}
|
||||
|
||||
# Verify all conditions are accounted for
|
||||
all_conditions = set(StatusCondition)
|
||||
assert stacking_conditions | overriding_conditions == all_conditions
|
||||
|
||||
|
||||
class TestActionType:
|
||||
"""Tests for the ActionType enum."""
|
||||
|
||||
def test_action_type_values(self) -> None:
|
||||
"""
|
||||
Verify ActionType has all expected player actions.
|
||||
|
||||
These are all the actions a player can take during their turn.
|
||||
"""
|
||||
expected_actions = {
|
||||
"play_pokemon",
|
||||
"evolve",
|
||||
"attach_energy",
|
||||
"play_trainer",
|
||||
"use_ability",
|
||||
"attack",
|
||||
"retreat",
|
||||
"pass",
|
||||
}
|
||||
actual_actions = {a.value for a in ActionType}
|
||||
assert actual_actions == expected_actions
|
||||
assert len(ActionType) == 8
|
||||
|
||||
|
||||
class TestGameEndReason:
|
||||
"""Tests for the GameEndReason enum."""
|
||||
|
||||
def test_game_end_reason_values(self) -> None:
|
||||
"""
|
||||
Verify GameEndReason has all expected game ending conditions.
|
||||
|
||||
These match the win conditions documented in GAME_RULES.md plus
|
||||
additional end conditions like resignation and timeout.
|
||||
"""
|
||||
assert GameEndReason.PRIZES_TAKEN == "prizes_taken"
|
||||
assert GameEndReason.NO_POKEMON == "no_pokemon"
|
||||
assert GameEndReason.DECK_EMPTY == "deck_empty"
|
||||
assert GameEndReason.RESIGNATION == "resignation"
|
||||
assert GameEndReason.TIMEOUT == "timeout"
|
||||
assert GameEndReason.DRAW == "draw"
|
||||
assert len(GameEndReason) == 6
|
||||
|
||||
|
||||
class TestEnumJsonRoundTrip:
|
||||
"""Tests for JSON serialization/deserialization of all enums."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"enum_class,sample_value",
|
||||
[
|
||||
(CardType, CardType.POKEMON),
|
||||
(PokemonStage, PokemonStage.BASIC),
|
||||
(EnergyType, EnergyType.FIRE),
|
||||
(TrainerType, TrainerType.SUPPORTER),
|
||||
(TurnPhase, TurnPhase.MAIN),
|
||||
(StatusCondition, StatusCondition.POISONED),
|
||||
(ActionType, ActionType.ATTACK),
|
||||
(GameEndReason, GameEndReason.PRIZES_TAKEN),
|
||||
],
|
||||
)
|
||||
def test_json_round_trip(self, enum_class: type, sample_value: str) -> None:
|
||||
"""
|
||||
Verify all enum types round-trip through JSON correctly.
|
||||
|
||||
This is critical for WebSocket communication and state persistence.
|
||||
Each enum should serialize to its string value and deserialize back.
|
||||
"""
|
||||
|
||||
class TestModel(BaseModel):
|
||||
value: enum_class # type: ignore[valid-type]
|
||||
|
||||
model = TestModel(value=sample_value)
|
||||
json_str = model.model_dump_json()
|
||||
restored = TestModel.model_validate_json(json_str)
|
||||
|
||||
assert restored.value == sample_value
|
||||
assert isinstance(restored.value, enum_class)
|
||||
511
backend/tests/core/test_rng.py
Normal file
511
backend/tests/core/test_rng.py
Normal file
@ -0,0 +1,511 @@
|
||||
"""Tests for the RandomProvider implementations.
|
||||
|
||||
These tests verify:
|
||||
1. SeededRandom produces deterministic results with the same seed
|
||||
2. SecureRandom produces varied results
|
||||
3. Both implementations satisfy the RandomProvider protocol
|
||||
4. Edge cases are handled correctly
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.rng import RandomProvider, SecureRandom, SeededRandom, create_rng
|
||||
|
||||
|
||||
class TestSeededRandom:
|
||||
"""Tests for the SeededRandom implementation."""
|
||||
|
||||
def test_same_seed_produces_same_sequence(self) -> None:
|
||||
"""
|
||||
Verify that two SeededRandom instances with the same seed produce
|
||||
identical sequences of random values.
|
||||
|
||||
This is critical for testing game logic that depends on randomness.
|
||||
"""
|
||||
rng1 = SeededRandom(seed=42)
|
||||
rng2 = SeededRandom(seed=42)
|
||||
|
||||
# Generate sequences of values
|
||||
seq1 = [rng1.randint(1, 100) for _ in range(10)]
|
||||
seq2 = [rng2.randint(1, 100) for _ in range(10)]
|
||||
|
||||
assert seq1 == seq2
|
||||
|
||||
def test_different_seeds_produce_different_sequences(self) -> None:
|
||||
"""
|
||||
Verify that different seeds produce different sequences.
|
||||
|
||||
This validates that the seed actually affects the output.
|
||||
"""
|
||||
rng1 = SeededRandom(seed=1)
|
||||
rng2 = SeededRandom(seed=2)
|
||||
|
||||
seq1 = [rng1.randint(1, 1000) for _ in range(10)]
|
||||
seq2 = [rng2.randint(1, 1000) for _ in range(10)]
|
||||
|
||||
assert seq1 != seq2
|
||||
|
||||
def test_random_returns_float_in_range(self) -> None:
|
||||
"""
|
||||
Verify random() returns floats in [0.0, 1.0).
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
|
||||
for _ in range(100):
|
||||
value = rng.random()
|
||||
assert 0.0 <= value < 1.0
|
||||
|
||||
def test_randint_returns_value_in_range(self) -> None:
|
||||
"""
|
||||
Verify randint() returns integers in the specified inclusive range.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
|
||||
for _ in range(100):
|
||||
value = rng.randint(1, 6)
|
||||
assert 1 <= value <= 6
|
||||
|
||||
def test_randint_with_equal_bounds(self) -> None:
|
||||
"""
|
||||
Verify randint() works when a == b.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
assert rng.randint(5, 5) == 5
|
||||
|
||||
def test_choice_selects_from_sequence(self) -> None:
|
||||
"""
|
||||
Verify choice() returns an element from the sequence.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
options = ["a", "b", "c", "d"]
|
||||
|
||||
for _ in range(100):
|
||||
assert rng.choice(options) in options
|
||||
|
||||
def test_choice_deterministic(self) -> None:
|
||||
"""
|
||||
Verify choice() is deterministic with same seed.
|
||||
"""
|
||||
options = ["fire", "water", "grass"]
|
||||
|
||||
rng1 = SeededRandom(seed=123)
|
||||
rng2 = SeededRandom(seed=123)
|
||||
|
||||
choices1 = [rng1.choice(options) for _ in range(10)]
|
||||
choices2 = [rng2.choice(options) for _ in range(10)]
|
||||
|
||||
assert choices1 == choices2
|
||||
|
||||
def test_shuffle_is_deterministic(self) -> None:
|
||||
"""
|
||||
Verify shuffle() produces same order with same seed.
|
||||
|
||||
This is important for testing deck shuffling.
|
||||
"""
|
||||
rng1 = SeededRandom(seed=42)
|
||||
rng2 = SeededRandom(seed=42)
|
||||
|
||||
list1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
list2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
rng1.shuffle(list1)
|
||||
rng2.shuffle(list2)
|
||||
|
||||
assert list1 == list2
|
||||
|
||||
def test_shuffle_modifies_in_place(self) -> None:
|
||||
"""
|
||||
Verify shuffle() modifies the list in place and returns None.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
original = [1, 2, 3, 4, 5]
|
||||
to_shuffle = original.copy()
|
||||
|
||||
result = rng.shuffle(to_shuffle)
|
||||
|
||||
assert result is None
|
||||
# With high probability, shuffled list differs from original
|
||||
# (1/120 chance they're the same for 5 elements)
|
||||
# Use a seed known to produce a different order
|
||||
assert to_shuffle != [1, 2, 3, 4, 5]
|
||||
|
||||
def test_coin_flip_is_deterministic(self) -> None:
|
||||
"""
|
||||
Verify coin_flip() produces same sequence with same seed.
|
||||
|
||||
This is critical for testing status condition removal.
|
||||
"""
|
||||
rng1 = SeededRandom(seed=42)
|
||||
rng2 = SeededRandom(seed=42)
|
||||
|
||||
flips1 = [rng1.coin_flip() for _ in range(20)]
|
||||
flips2 = [rng2.coin_flip() for _ in range(20)]
|
||||
|
||||
assert flips1 == flips2
|
||||
|
||||
def test_coin_flip_produces_both_outcomes(self) -> None:
|
||||
"""
|
||||
Verify coin_flip() can produce both True and False.
|
||||
|
||||
With 1000 flips, we should see both outcomes.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
flips = [rng.coin_flip() for _ in range(1000)]
|
||||
|
||||
assert True in flips
|
||||
assert False in flips
|
||||
|
||||
def test_sample_returns_correct_count(self) -> None:
|
||||
"""
|
||||
Verify sample() returns exactly k elements.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
population = list(range(100))
|
||||
|
||||
result = rng.sample(population, 10)
|
||||
|
||||
assert len(result) == 10
|
||||
|
||||
def test_sample_returns_unique_elements(self) -> None:
|
||||
"""
|
||||
Verify sample() returns unique elements (no duplicates).
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
population = list(range(100))
|
||||
|
||||
result = rng.sample(population, 10)
|
||||
|
||||
assert len(result) == len(set(result))
|
||||
|
||||
def test_sample_elements_from_population(self) -> None:
|
||||
"""
|
||||
Verify sample() only returns elements from the population.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
population = ["a", "b", "c", "d", "e"]
|
||||
|
||||
result = rng.sample(population, 3)
|
||||
|
||||
for item in result:
|
||||
assert item in population
|
||||
|
||||
def test_sample_is_deterministic(self) -> None:
|
||||
"""
|
||||
Verify sample() is deterministic with same seed.
|
||||
"""
|
||||
population = list(range(50))
|
||||
|
||||
rng1 = SeededRandom(seed=42)
|
||||
rng2 = SeededRandom(seed=42)
|
||||
|
||||
sample1 = rng1.sample(population, 5)
|
||||
sample2 = rng2.sample(population, 5)
|
||||
|
||||
assert sample1 == sample2
|
||||
|
||||
def test_seed_property(self) -> None:
|
||||
"""
|
||||
Verify the seed property returns the initialization seed.
|
||||
"""
|
||||
rng = SeededRandom(seed=12345)
|
||||
assert rng.seed == 12345
|
||||
|
||||
def test_none_seed_stored(self) -> None:
|
||||
"""
|
||||
Verify None seed is stored correctly.
|
||||
"""
|
||||
rng = SeededRandom(seed=None)
|
||||
assert rng.seed is None
|
||||
|
||||
|
||||
class TestSecureRandom:
|
||||
"""Tests for the SecureRandom implementation."""
|
||||
|
||||
def test_random_returns_float_in_range(self) -> None:
|
||||
"""
|
||||
Verify random() returns floats in [0.0, 1.0).
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
|
||||
for _ in range(100):
|
||||
value = rng.random()
|
||||
assert 0.0 <= value < 1.0
|
||||
|
||||
def test_randint_returns_value_in_range(self) -> None:
|
||||
"""
|
||||
Verify randint() returns integers in the specified inclusive range.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
|
||||
for _ in range(100):
|
||||
value = rng.randint(1, 6)
|
||||
assert 1 <= value <= 6
|
||||
|
||||
def test_randint_with_equal_bounds(self) -> None:
|
||||
"""
|
||||
Verify randint() works when a == b.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
assert rng.randint(5, 5) == 5
|
||||
|
||||
def test_randint_invalid_range_raises(self) -> None:
|
||||
"""
|
||||
Verify randint() raises ValueError when a > b.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
with pytest.raises(ValueError, match="must be <="):
|
||||
rng.randint(10, 5)
|
||||
|
||||
def test_choice_selects_from_sequence(self) -> None:
|
||||
"""
|
||||
Verify choice() returns an element from the sequence.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
options = ["a", "b", "c", "d"]
|
||||
|
||||
for _ in range(100):
|
||||
assert rng.choice(options) in options
|
||||
|
||||
def test_choice_empty_sequence_raises(self) -> None:
|
||||
"""
|
||||
Verify choice() raises IndexError on empty sequence.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
with pytest.raises(IndexError, match="empty sequence"):
|
||||
rng.choice([])
|
||||
|
||||
def test_shuffle_changes_order(self) -> None:
|
||||
"""
|
||||
Verify shuffle() changes the order of elements.
|
||||
|
||||
Note: There's a tiny chance this fails if shuffle happens
|
||||
to produce the original order, but it's negligible for
|
||||
lists of reasonable size.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
original = list(range(20))
|
||||
shuffled = original.copy()
|
||||
|
||||
rng.shuffle(shuffled)
|
||||
|
||||
# Very unlikely to be in original order
|
||||
assert shuffled != original
|
||||
|
||||
def test_shuffle_preserves_elements(self) -> None:
|
||||
"""
|
||||
Verify shuffle() preserves all elements.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
original = [1, 2, 3, 4, 5]
|
||||
shuffled = original.copy()
|
||||
|
||||
rng.shuffle(shuffled)
|
||||
|
||||
assert sorted(shuffled) == sorted(original)
|
||||
|
||||
def test_coin_flip_produces_both_outcomes(self) -> None:
|
||||
"""
|
||||
Verify coin_flip() produces both True and False.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
flips = [rng.coin_flip() for _ in range(100)]
|
||||
|
||||
assert True in flips
|
||||
assert False in flips
|
||||
|
||||
def test_sample_returns_correct_count(self) -> None:
|
||||
"""
|
||||
Verify sample() returns exactly k elements.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
population = list(range(100))
|
||||
|
||||
result = rng.sample(population, 10)
|
||||
|
||||
assert len(result) == 10
|
||||
|
||||
def test_sample_returns_unique_elements(self) -> None:
|
||||
"""
|
||||
Verify sample() returns unique elements.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
population = list(range(100))
|
||||
|
||||
result = rng.sample(population, 10)
|
||||
|
||||
assert len(result) == len(set(result))
|
||||
|
||||
def test_sample_too_large_raises(self) -> None:
|
||||
"""
|
||||
Verify sample() raises ValueError when k > population size.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
with pytest.raises(ValueError, match="larger than population"):
|
||||
rng.sample([1, 2, 3], 5)
|
||||
|
||||
def test_sample_negative_raises(self) -> None:
|
||||
"""
|
||||
Verify sample() raises ValueError for negative k.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
with pytest.raises(ValueError, match="non-negative"):
|
||||
rng.sample([1, 2, 3], -1)
|
||||
|
||||
def test_produces_varied_results(self) -> None:
|
||||
"""
|
||||
Verify SecureRandom produces varied results across calls.
|
||||
|
||||
This is a statistical test - we check that repeated calls
|
||||
don't all return the same value.
|
||||
"""
|
||||
rng = SecureRandom()
|
||||
|
||||
# Generate many random values
|
||||
values = [rng.randint(1, 1000000) for _ in range(100)]
|
||||
|
||||
# Should have many unique values
|
||||
unique_values = set(values)
|
||||
assert len(unique_values) > 90 # At least 90% unique
|
||||
|
||||
|
||||
class TestRandomProviderProtocol:
|
||||
"""Tests verifying both implementations satisfy the protocol."""
|
||||
|
||||
@pytest.mark.parametrize("rng_class", [SeededRandom, SecureRandom])
|
||||
def test_implements_random_provider(self, rng_class: type) -> None:
|
||||
"""
|
||||
Verify both implementations can be used as RandomProvider.
|
||||
|
||||
This ensures the protocol is properly implemented.
|
||||
"""
|
||||
if rng_class == SeededRandom:
|
||||
rng: RandomProvider = rng_class(seed=42)
|
||||
else:
|
||||
rng = rng_class()
|
||||
|
||||
# All protocol methods should be callable
|
||||
assert callable(rng.random)
|
||||
assert callable(rng.randint)
|
||||
assert callable(rng.choice)
|
||||
assert callable(rng.shuffle)
|
||||
assert callable(rng.coin_flip)
|
||||
assert callable(rng.sample)
|
||||
|
||||
@pytest.mark.parametrize("rng_class", [SeededRandom, SecureRandom])
|
||||
def test_methods_return_correct_types(self, rng_class: type) -> None:
|
||||
"""
|
||||
Verify protocol methods return expected types.
|
||||
"""
|
||||
if rng_class == SeededRandom:
|
||||
rng: RandomProvider = rng_class(seed=42)
|
||||
else:
|
||||
rng = rng_class()
|
||||
|
||||
assert isinstance(rng.random(), float)
|
||||
assert isinstance(rng.randint(1, 10), int)
|
||||
assert isinstance(rng.choice([1, 2, 3]), int)
|
||||
assert isinstance(rng.coin_flip(), bool)
|
||||
assert isinstance(rng.sample([1, 2, 3, 4, 5], 2), list)
|
||||
|
||||
|
||||
class TestCreateRng:
|
||||
"""Tests for the create_rng factory function."""
|
||||
|
||||
def test_with_seed_returns_seeded_random(self) -> None:
|
||||
"""
|
||||
Verify create_rng with seed returns SeededRandom.
|
||||
"""
|
||||
rng = create_rng(seed=42)
|
||||
assert isinstance(rng, SeededRandom)
|
||||
assert rng.seed == 42
|
||||
|
||||
def test_with_secure_returns_secure_random(self) -> None:
|
||||
"""
|
||||
Verify create_rng with secure=True returns SecureRandom.
|
||||
"""
|
||||
rng = create_rng(secure=True)
|
||||
assert isinstance(rng, SecureRandom)
|
||||
|
||||
def test_default_returns_seeded_random(self) -> None:
|
||||
"""
|
||||
Verify create_rng with no args returns unseeded SeededRandom.
|
||||
"""
|
||||
rng = create_rng()
|
||||
assert isinstance(rng, SeededRandom)
|
||||
|
||||
def test_seed_takes_precedence_over_secure(self) -> None:
|
||||
"""
|
||||
Verify that providing a seed returns SeededRandom even if secure=True.
|
||||
|
||||
The seed parameter takes precedence because if you're specifying a seed,
|
||||
you clearly want reproducibility.
|
||||
"""
|
||||
rng = create_rng(seed=42, secure=True)
|
||||
assert isinstance(rng, SeededRandom)
|
||||
assert rng.seed == 42
|
||||
|
||||
|
||||
class TestGameUseCases:
|
||||
"""Tests simulating actual game engine use cases."""
|
||||
|
||||
def test_deck_shuffle_reproducibility(self) -> None:
|
||||
"""
|
||||
Verify deck shuffling is reproducible for testing.
|
||||
|
||||
This simulates initializing a game with the same seed
|
||||
and verifying the deck order is identical.
|
||||
"""
|
||||
deck = list(range(1, 41)) # 40 card deck
|
||||
|
||||
rng1 = SeededRandom(seed=12345)
|
||||
deck1 = deck.copy()
|
||||
rng1.shuffle(deck1)
|
||||
|
||||
rng2 = SeededRandom(seed=12345)
|
||||
deck2 = deck.copy()
|
||||
rng2.shuffle(deck2)
|
||||
|
||||
assert deck1 == deck2
|
||||
|
||||
def test_coin_flip_for_status_removal(self) -> None:
|
||||
"""
|
||||
Verify coin flips work correctly for status condition removal.
|
||||
|
||||
With a known seed, we can test specific scenarios.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
|
||||
# Simulate checking if burn is removed (flip at end of turn)
|
||||
flips = [rng.coin_flip() for _ in range(10)]
|
||||
|
||||
# We don't care about the specific values, just that they're
|
||||
# consistent and boolean
|
||||
assert all(isinstance(f, bool) for f in flips)
|
||||
|
||||
def test_prize_card_selection(self) -> None:
|
||||
"""
|
||||
Verify random prize card selection works correctly.
|
||||
|
||||
When using classic prize card rules, a random card must be selected.
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
prize_cards = ["card_1", "card_2", "card_3", "card_4", "card_5", "card_6"]
|
||||
|
||||
selected = rng.choice(prize_cards)
|
||||
|
||||
assert selected in prize_cards
|
||||
|
||||
def test_damage_calculation_with_randomness(self) -> None:
|
||||
"""
|
||||
Verify random damage modifiers work correctly.
|
||||
|
||||
Some attacks might have random damage (e.g., "flip a coin, if heads +20").
|
||||
"""
|
||||
rng = SeededRandom(seed=42)
|
||||
base_damage = 30
|
||||
|
||||
# Simulate attack with coin flip bonus
|
||||
total_damage = base_damage + 20 if rng.coin_flip() else base_damage
|
||||
|
||||
# Just verify the result is one of the two possibilities
|
||||
assert total_damage in [30, 50]
|
||||
Loading…
Reference in New Issue
Block a user