#!/usr/bin/env python """Engine validation script - reproducible walkthrough of game engine validation. Usage: cd backend && uv run python references/engine_validation.py This script demonstrates both successful operations and rejected illegal moves, validating that the game engine correctly enforces rules. Uses seed=42 for deterministic coin flips, ensuring reproducible results. Press Enter to continue through each step, or 'q' to quit. Validation Scenarios: 1. Setup - Create game with stacked decks 2. Illegal Moves - Verify rejected actions with clear error messages 3. Energy Mechanics - Attachment and attack cost validation 4. Weakness Demo - Additive +20 weakness calculation 5. Status Conditions - Paralysis and Poison effects 6. Knockout Flow - Points, forced actions, and state cleanup 7. Win Condition - Game ends at 4 points """ # ============================================================================= # PATH SETUP # ============================================================================= import sys from pathlib import Path backend_dir = Path(__file__).resolve().parent.parent if str(backend_dir) not in sys.path: sys.path.insert(0, str(backend_dir)) # ============================================================================= # IMPORTS # ============================================================================= import asyncio # Register effect handlers import app.core.effects.handlers # noqa: F401 from app.core import ( CardType, EnergyType, GameEngine, GameState, PokemonStage, RulesConfig, TurnPhase, create_rng, ) from app.core.enums import ModifierMode, StatusCondition from app.core.models import ( AttachEnergyAction, Attack, AttackAction, CardDefinition, CardInstance, EvolvePokemonAction, PassAction, RetreatAction, SelectActiveAction, WeaknessResistance, ) # ============================================================================= # TERMINAL COLORS # ============================================================================= class Colors: """ANSI color codes for terminal output.""" HEADER = "\033[95m" BLUE = "\033[94m" CYAN = "\033[96m" GREEN = "\033[92m" YELLOW = "\033[93m" RED = "\033[91m" BOLD = "\033[1m" UNDERLINE = "\033[4m" END = "\033[0m" def header(text: str) -> str: """Format text as a header.""" return f"{Colors.BOLD}{Colors.HEADER}{text}{Colors.END}" def success(text: str) -> str: """Format text as success (green).""" return f"{Colors.GREEN}{text}{Colors.END}" def info(text: str) -> str: """Format text as info (cyan).""" return f"{Colors.CYAN}{text}{Colors.END}" def warning(text: str) -> str: """Format text as warning (yellow).""" return f"{Colors.YELLOW}{text}{Colors.END}" def error(text: str) -> str: """Format text as error (red).""" return f"{Colors.RED}{text}{Colors.END}" def bold(text: str) -> str: """Format text as bold.""" return f"{Colors.BOLD}{text}{Colors.END}" # ============================================================================= # DISPLAY HELPERS # ============================================================================= def print_divider(char: str = "=", width: int = 70): """Print a divider line.""" print(char * width) def print_section(title: str): """Print a section header.""" print() print_divider() print(header(title)) print_divider() def print_subsection(title: str): """Print a subsection header.""" print() print(f"{Colors.CYAN}--- {title} ---{Colors.END}") def print_step(step: str, description: str): """Print a step in the validation.""" print(f"\n{Colors.YELLOW}[{step}]{Colors.END} {description}") def print_action(text: str): """Print an action being taken.""" print(f" {Colors.YELLOW}>>> {text}{Colors.END}") def print_result(text: str): """Print the result of an action.""" print(f" {Colors.GREEN} {text}{Colors.END}") def print_expected_failure(text: str): """Print an expected failure (validation working correctly).""" print(f" {Colors.RED} REJECTED: {text}{Colors.END}") def print_validation_pass(text: str): """Print a validation pass message.""" print(f" {Colors.GREEN}[PASS]{Colors.END} {text}") def print_validation_fail(text: str): """Print a validation failure message.""" print(f" {Colors.RED}[FAIL]{Colors.END} {text}") def show_game_state(game: GameState): """Display the current game state in a nice format.""" print() print(f" {bold('Turn:')} {game.turn_number} | {bold('Phase:')} {game.phase.value} | {bold('Current Player:')} {game.current_player_id}") for player_id in game.turn_order: player = game.players[player_id] is_current = player_id == game.current_player_id marker = " *" if is_current else "" print(f"\n {bold(f'Player: {player_id}{marker}')}") print(f" Score: {player.score} / {game.rules.prizes.count} to win") # Active Pokemon active = player.get_active_pokemon() if active: card_def = game.get_card_definition(active.definition_id) if card_def: max_hp = card_def.hp + active.hp_modifier current_hp = max_hp - active.damage status = ", ".join(s.value for s in active.status_conditions) or "none" energy = len(active.attached_energy) print(f" {bold('Active:')} {card_def.name} ({current_hp}/{max_hp} HP, {energy} energy, status: {status})") else: print(f" {bold('Active:')} {warning('None!')}") # Bench bench_names = [] for card in player.bench.cards: card_def = game.get_card_definition(card.definition_id) if card_def: max_hp = card_def.hp + card.hp_modifier current_hp = max_hp - card.damage bench_names.append(f"{card_def.name} ({current_hp}/{max_hp})") bench_str = ", ".join(bench_names) if bench_names else "empty" print(f" {bold('Bench:')} {bench_str}") print() def show_pokemon_status(game: GameState, player_id: str, label: str = ""): """Show a single player's active Pokemon status.""" player = game.players[player_id] active = player.get_active_pokemon() if active: card_def = game.get_card_definition(active.definition_id) if card_def: max_hp = card_def.hp + active.hp_modifier current_hp = max_hp - active.damage status = ", ".join(s.value for s in active.status_conditions) or "none" energy = len(active.attached_energy) prefix = f"{label}: " if label else "" print(f" {prefix}{card_def.name} ({current_hp}/{max_hp} HP, {energy} energy, status: {status})") def wait_for_continue() -> bool: """Wait for user to press Enter. Returns False if user wants to quit.""" try: response = input(f"\n{info('[Press Enter to continue, or q to quit]')} ") return response.lower() != "q" except (KeyboardInterrupt, EOFError): return False # ============================================================================= # CARD DEFINITIONS (Based on real Pokemon Pocket cards) # ============================================================================= def create_card_definitions() -> dict[str, CardDefinition]: """Create all card definitions for validation. Uses real card stats from Pokemon Pocket where possible, but adds effect_id for attack effects since JSON cards only have effect_description. """ cards = {} # === POKEMON CARDS === # Pincurchin - from a1/112 # Lightning, 70HP, Thunder Shock (30 dmg, coin flip paralysis) cards["pincurchin-001"] = CardDefinition( id="pincurchin-001", name="Pincurchin", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, hp=70, pokemon_type=EnergyType.LIGHTNING, attacks=[ Attack( name="Thunder Shock", cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING], damage=30, effect_id="attack_coin_status", effect_params={"status": "paralyzed"}, effect_description="Flip a coin. If heads, opponent's Active is Paralyzed.", ), ], weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, mode=ModifierMode.ADDITIVE, value=20), retreat_cost=1, ) # Tentacruel - from a1/063 # Water, 110HP, weak to Lightning (+20) cards["tentacruel-001"] = CardDefinition( id="tentacruel-001", name="Tentacruel", card_type=CardType.POKEMON, stage=PokemonStage.STAGE_1, evolves_from="Tentacool", hp=110, pokemon_type=EnergyType.WATER, attacks=[ Attack( name="Poison Tentacles", cost=[EnergyType.WATER, EnergyType.COLORLESS], damage=50, effect_id="apply_status", effect_params={"status": "poisoned"}, effect_description="Opponent's Active is now Poisoned.", ), ], weakness=WeaknessResistance(energy_type=EnergyType.LIGHTNING, mode=ModifierMode.ADDITIVE, value=20), retreat_cost=2, ) # Tentacool - basic for Tentacruel evolution (from a1/062) cards["tentacool-001"] = CardDefinition( id="tentacool-001", name="Tentacool", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, hp=60, pokemon_type=EnergyType.WATER, attacks=[ Attack( name="Gentle Slap", cost=[EnergyType.COLORLESS], damage=20, ), ], weakness=WeaknessResistance(energy_type=EnergyType.LIGHTNING, mode=ModifierMode.ADDITIVE, value=20), retreat_cost=1, ) # Grimer - from a1/174 # Darkness, 70HP, Poison Gas (10 dmg, applies poison) cards["grimer-001"] = CardDefinition( id="grimer-001", name="Grimer", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, hp=70, pokemon_type=EnergyType.DARKNESS, attacks=[ Attack( name="Poison Gas", cost=[EnergyType.DARKNESS], damage=10, effect_id="apply_status", effect_params={"status": "poisoned"}, effect_description="Opponent's Active is now Poisoned.", ), ], weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, mode=ModifierMode.ADDITIVE, value=20), retreat_cost=3, ) # Rattata - Basic Normal for evolution testing cards["rattata-001"] = CardDefinition( id="rattata-001", name="Rattata", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, hp=40, pokemon_type=EnergyType.COLORLESS, attacks=[ Attack( name="Bite", cost=[EnergyType.COLORLESS], damage=20, ), ], weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING), retreat_cost=0, ) # Pikachu - for testing wrong evolution chain cards["pikachu-001"] = CardDefinition( id="pikachu-001", name="Pikachu", card_type=CardType.POKEMON, stage=PokemonStage.BASIC, hp=60, pokemon_type=EnergyType.LIGHTNING, attacks=[ Attack( name="Gnaw", cost=[], damage=10, ), ], weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING), retreat_cost=1, ) # Raichu - Stage 1 for evolution testing (evolves from Pikachu, not Rattata) cards["raichu-001"] = CardDefinition( id="raichu-001", name="Raichu", card_type=CardType.POKEMON, stage=PokemonStage.STAGE_1, evolves_from="Pikachu", hp=100, pokemon_type=EnergyType.LIGHTNING, attacks=[ Attack( name="Thunder Punch", cost=[EnergyType.LIGHTNING], damage=50, ), ], weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING), retreat_cost=1, ) # === ENERGY CARDS === for energy_type in [EnergyType.LIGHTNING, EnergyType.WATER, EnergyType.DARKNESS]: cards[f"{energy_type.value}-energy"] = CardDefinition( id=f"{energy_type.value}-energy", name=f"{energy_type.value.title()} Energy", card_type=CardType.ENERGY, energy_type=energy_type, energy_provides=[energy_type], ) return cards # ============================================================================= # DECK BUILDERS (Stacked for predictable scenarios) # ============================================================================= def build_player1_deck( card_registry: dict[str, CardDefinition], ) -> tuple[list[CardInstance], list[CardInstance]]: """Build Player 1's deck - Pincurchin (Lightning) + filler. Stack order (top of deck first): - Pincurchin x3 (first will be in starting hand, ensuring active) - Rattata x1 (for wrong evolution test) - Pikachu x1 (for correct evolution test) - Raichu x1 (evolution card) - Filler to reach 40 cards """ main_deck = [] energy_deck = [] # Stack top of deck for predictable hands # After shuffle, these will be in known positions due to seed=42 # But we'll manipulate hand directly for cleaner test setup for i in range(3): main_deck.append(CardInstance(instance_id=f"p1-pincurchin-{i}", definition_id="pincurchin-001")) main_deck.append(CardInstance(instance_id="p1-rattata-0", definition_id="rattata-001")) main_deck.append(CardInstance(instance_id="p1-pikachu-0", definition_id="pikachu-001")) main_deck.append(CardInstance(instance_id="p1-raichu-0", definition_id="raichu-001")) # Pad to 40 cards idx = 0 while len(main_deck) < 40: main_deck.append(CardInstance(instance_id=f"p1-pincurchin-extra-{idx}", definition_id="pincurchin-001")) idx += 1 # Energy deck - 20 Lightning for i in range(20): energy_deck.append(CardInstance(instance_id=f"p1-lightning-{i}", definition_id="lightning-energy")) return main_deck, energy_deck def build_player2_deck( card_registry: dict[str, CardDefinition], ) -> tuple[list[CardInstance], list[CardInstance]]: """Build Player 2's deck - Tentacool/Tentacruel (Water) + Grimer. Stack for predictable scenarios: - Tentacool x3 (for active and bench, weak to Lightning) - Tentacruel x2 (for evolution, also weak to Lightning) - Grimer x2 (for poison attack) - Filler """ main_deck = [] energy_deck = [] for i in range(3): main_deck.append(CardInstance(instance_id=f"p2-tentacool-{i}", definition_id="tentacool-001")) for i in range(2): main_deck.append(CardInstance(instance_id=f"p2-tentacruel-{i}", definition_id="tentacruel-001")) for i in range(2): main_deck.append(CardInstance(instance_id=f"p2-grimer-{i}", definition_id="grimer-001")) # Pad to 40 cards idx = 0 while len(main_deck) < 40: main_deck.append(CardInstance(instance_id=f"p2-tentacool-extra-{idx}", definition_id="tentacool-001")) idx += 1 # Energy deck - mix of Water and Darkness for i in range(12): energy_deck.append(CardInstance(instance_id=f"p2-water-{i}", definition_id="water-energy")) for i in range(8): energy_deck.append(CardInstance(instance_id=f"p2-darkness-{i}", definition_id="darkness-energy")) return main_deck, energy_deck # ============================================================================= # VALIDATION HELPERS # ============================================================================= class ValidationTracker: """Track validation results for summary.""" def __init__(self): self.passed = 0 self.failed = 0 self.results = [] def check(self, condition: bool, description: str) -> bool: """Record a validation check result.""" if condition: self.passed += 1 self.results.append((True, description)) print_validation_pass(description) else: self.failed += 1 self.results.append((False, description)) print_validation_fail(description) return condition def summary(self): """Print summary of all validations.""" print_section("VALIDATION SUMMARY") total = self.passed + self.failed print(f"\n Total checks: {total}") print(f" {success(f'Passed: {self.passed}')}") if self.failed > 0: print(f" {error(f'Failed: {self.failed}')}") else: print(f" Failed: {self.failed}") if self.failed == 0: print(f"\n {success('ALL VALIDATIONS PASSED!')}") else: print(f"\n {error('SOME VALIDATIONS FAILED:')}") for passed, desc in self.results: if not passed: print(f" - {desc}") # ============================================================================= # MAIN VALIDATION # ============================================================================= async def run_validation(): """Run the full engine validation suite.""" print_divider("*") print(header(" MANTIMON TCG - ENGINE VALIDATION SCRIPT")) print_divider("*") print(f"\n{info('This script validates game engine behavior with reproducible scenarios.')}") print(f"{info('Using seed=42 for deterministic coin flips.')}") print(f"{info('Press Enter to advance through each step, or q to quit.')}") if not wait_for_continue(): print("\nGoodbye!") return tracker = ValidationTracker() # ========================================================================= # PART 1: SETUP # ========================================================================= print_section("PART 1: SETUP") print_step("1.1", "Creating card definitions") card_registry = create_card_definitions() print_result(f"Created {len(card_registry)} card definitions") print_step("1.2", "Creating rules and engine") rules = RulesConfig() rng = create_rng(seed=42) engine = GameEngine(rules=rules, rng=rng) print_result("Engine created with seed=42") print_step("1.3", "Building player decks") p1_deck, p1_energy = build_player1_deck(card_registry) p2_deck, p2_energy = build_player2_deck(card_registry) print_result(f"Player 1: {len(p1_deck)} cards main, {len(p1_energy)} cards energy") print_result(f"Player 2: {len(p2_deck)} cards main, {len(p2_energy)} cards energy") print_step("1.4", "Creating game") decks = {"player1": p1_deck, "player2": p2_deck} energy_decks = {"player1": p1_energy, "player2": p2_energy} creation = engine.create_game( player_ids=["player1", "player2"], decks=decks, energy_decks=energy_decks, card_registry=card_registry, ) tracker.check(creation.success, "Game creation succeeded") game = creation.game print_result(f"Game ID: {game.game_id[:8]}...") print_result(f"First player: {game.current_player_id}") print_step("1.5", "Setting up controlled game state") # Manually set up the game state for predictable testing # This bypasses normal setup to get exact positions needed # Set player1 to go first game.current_player_id = "player1" # Clear hands and set up specific cards p1 = game.players["player1"] p2 = game.players["player2"] # Clear hands p1.hand.cards.clear() p2.hand.cards.clear() # Set up P1: Pincurchin active, Rattata + Pikachu on bench # (Rattata for wrong evolution test, Pikachu for correct evolution test) pincurchin = CardInstance(instance_id="p1-pincurchin-active", definition_id="pincurchin-001") p1.active.add(pincurchin) rattata = CardInstance(instance_id="p1-rattata-bench", definition_id="rattata-001") p1.bench.add(rattata) pikachu = CardInstance(instance_id="p1-pikachu-bench", definition_id="pikachu-001") p1.bench.add(pikachu) # Add Raichu to hand for evolution test raichu = CardInstance(instance_id="p1-raichu-hand", definition_id="raichu-001") p1.hand.add(raichu) # Set up P2: Tentacruel active (110 HP, survives weakness hit), Grimer + Tentacool on bench # Using Tentacruel instead of Tentacool for weakness demo (survives 50 damage) tentacruel_active = CardInstance(instance_id="p2-tentacruel-active", definition_id="tentacruel-001") p2.active.add(tentacruel_active) grimer = CardInstance(instance_id="p2-grimer-bench", definition_id="grimer-001") p2.bench.add(grimer) tentacool_bench = CardInstance(instance_id="p2-tentacool-bench", definition_id="tentacool-001") p2.bench.add(tentacool_bench) # Make sure energy zones have energy p1_energy_card = CardInstance(instance_id="p1-lightning-zone-0", definition_id="lightning-energy") p1.energy_zone.add(p1_energy_card) # Set to main phase for actions game.phase = TurnPhase.MAIN game.turn_number = 1 print_result("Game state configured for testing") show_game_state(game) if not wait_for_continue(): print("\nGoodbye!") return # ========================================================================= # PART 2: ILLEGAL MOVES VALIDATION # ========================================================================= print_section("PART 2: ILLEGAL MOVES VALIDATION") # 2.1: Attack without sufficient energy print_step("2.1", "Attack without sufficient energy") print_action("Pincurchin has 0 energy attached") print_action("Thunder Shock costs [Lightning, Lightning]") print_action("Moving to ATTACK phase and attempting attack...") # Must be in ATTACK phase to test energy validation game.phase = TurnPhase.ATTACK attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player1", attack_action) # Reset to MAIN phase for subsequent tests game.phase = TurnPhase.MAIN tracker.check(not result.success, "Attack without energy rejected") if not result.success: print_expected_failure(result.message) tracker.check( "energy" in result.message.lower() or "insufficient" in result.message.lower(), "Error message mentions energy requirement" ) # 2.2: Wrong player's turn print_step("2.2", "Wrong player's turn") print_action("It's player1's turn") print_action("Player2 attempts to attach energy...") p2_energy = CardInstance(instance_id="p2-water-zone-0", definition_id="water-energy") p2.energy_zone.add(p2_energy) attach_action = AttachEnergyAction( energy_card_id="p2-water-zone-0", target_pokemon_id="p2-tentacool-active", from_energy_zone=True, ) result = await engine.execute_action(game, "player2", attach_action) tracker.check(not result.success, "Action by wrong player rejected") if not result.success: print_expected_failure(result.message) tracker.check( "turn" in result.message.lower() or "player" in result.message.lower(), "Error message mentions turn/player" ) # 2.3: Evolve on first turn print_step("2.3", "Evolve on first turn") print_action(f"Current turn number: {game.turn_number}") print_action("Attempting to evolve Pikachu into Raichu on turn 1...") # Mark Pikachu as played on turn 1 (just placed) pikachu.turn_played = 1 evolve_action = EvolvePokemonAction( evolution_card_id="p1-raichu-hand", target_pokemon_id="p1-pikachu-bench", ) result = await engine.execute_action(game, "player1", evolve_action) tracker.check(not result.success, "Evolution on first turn rejected") if not result.success: print_expected_failure(result.message) tracker.check( "first turn" in result.message.lower() or "turn 1" in result.message.lower() or "same turn" in result.message.lower(), "Error message mentions first turn restriction" ) # 2.4: Wrong evolution chain print_step("2.4", "Wrong evolution chain") print_action("Raichu evolves from Pikachu, not Rattata") print_action("Attempting to evolve Rattata into Raichu...") # Advance turn so evolution is allowed, but chain is wrong game.turn_number = 2 rattata.turn_played = 1 # Played on previous turn wrong_evolve = EvolvePokemonAction( evolution_card_id="p1-raichu-hand", target_pokemon_id="p1-rattata-bench", ) result = await engine.execute_action(game, "player1", wrong_evolve) tracker.check(not result.success, "Wrong evolution chain rejected") if not result.success: print_expected_failure(result.message) tracker.check( "pikachu" in result.message.lower() or "evolution" in result.message.lower() or "evolves from" in result.message.lower(), "Error message mentions correct evolution chain" ) if not wait_for_continue(): print("\nGoodbye!") return # ========================================================================= # PART 3: ENERGY MECHANICS # ========================================================================= print_section("PART 3: ENERGY MECHANICS") print_step("3.1", "Attach energy from energy zone") print_action("Attaching Lightning energy to Pincurchin...") attach_action = AttachEnergyAction( energy_card_id="p1-lightning-zone-0", target_pokemon_id="p1-pincurchin-active", from_energy_zone=True, ) result = await engine.execute_action(game, "player1", attach_action) tracker.check(result.success, "Energy attachment succeeded") active = p1.get_active_pokemon() tracker.check(len(active.attached_energy) == 1, "Pokemon has 1 energy attached") print_result(f"Pincurchin now has {len(active.attached_energy)} energy") print_step("3.2", "Second energy attachment (should fail)") print_action("Attempting second energy attachment this turn...") # Add another energy to zone p1_energy_2 = CardInstance(instance_id="p1-lightning-zone-1", definition_id="lightning-energy") p1.energy_zone.add(p1_energy_2) attach_action_2 = AttachEnergyAction( energy_card_id="p1-lightning-zone-1", target_pokemon_id="p1-pincurchin-active", from_energy_zone=True, ) result = await engine.execute_action(game, "player1", attach_action_2) tracker.check(not result.success, "Second energy attachment rejected") if not result.success: print_expected_failure(result.message) print_step("3.3", "Attack still requires more energy") print_action("Pincurchin has 1 energy, Thunder Shock costs 2") print_action("Moving to ATTACK phase and attempting attack...") # Must be in ATTACK phase to test energy validation game.phase = TurnPhase.ATTACK attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player1", attack_action) # Reset to MAIN phase for subsequent tests game.phase = TurnPhase.MAIN tracker.check(not result.success, "Attack with insufficient energy rejected") if not result.success: print_expected_failure(result.message) tracker.check( "energy" in result.message.lower() or "insufficient" in result.message.lower(), "Error message mentions energy requirement" ) if not wait_for_continue(): print("\nGoodbye!") return # ========================================================================= # PART 4: WEAKNESS DEMO # ========================================================================= print_section("PART 4: WEAKNESS DEMONSTRATION") print_step("4.1", "Setting up for weakness test") print_action("Adding second energy to Pincurchin to enable attack") # Add energy directly to Pokemon for test (bypassing turn limit) energy_for_test = CardInstance(instance_id="p1-lightning-test", definition_id="lightning-energy") active.attach_energy(energy_for_test) print_result(f"Pincurchin now has {len(active.attached_energy)} energy") print_step("4.2", "Checking type matchup") print_action("Pincurchin (Lightning) vs Tentacruel (Water, weak to Lightning +20)") print_action("Tentacruel has 110 HP (survives the hit)") print_action("Thunder Shock base damage: 30") print_action("Expected damage: 30 + 20 = 50") # Advance to attack phase game.phase = TurnPhase.MAIN print_step("4.3", "Executing attack") # Move to attack phase first game.phase = TurnPhase.ATTACK attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player1", attack_action) tracker.check(result.success, "Attack executed successfully") # Check damage on opponent p2_active = p2.get_active_pokemon() print_result(f"Attack result: {result.message}") if p2_active: card_def = game.get_card_definition(p2_active.definition_id) print_result(f"Tentacruel took {p2_active.damage} damage (HP: {card_def.hp - p2_active.damage}/{card_def.hp})") tracker.check(p2_active.damage == 50, f"Weakness applied correctly: expected 50, got {p2_active.damage}") # Note: The attack also triggers coin flip for paralysis via attack_coin_status # With seed=42, we know the result - let's check if StatusCondition.PARALYZED in p2_active.status_conditions: print_result("Coin flip: HEADS - Tentacruel is paralyzed!") else: print_result("Coin flip: TAILS - No paralysis") else: print_result("Opponent's Pokemon was knocked out (unexpected for Tentacruel 110 HP)") tracker.check(False, "Tentacruel should survive 50 damage") if not wait_for_continue(): print("\nGoodbye!") return # ========================================================================= # PART 5: STATUS CONDITIONS # ========================================================================= print_section("PART 5: STATUS CONDITIONS") # First, end player1's turn properly print_step("5.1", "Ending player1's turn") # End P1's turn end_result = engine.end_turn(game) print_result(f"Turn ended: {end_result.message}") print_result(f"Now player2's turn, turn number: {game.turn_number}") # Start P2's turn start_result = engine.start_turn(game) print_result(f"Turn started: {start_result.message}") # Check paralysis prevents attack print_step("5.2", "Paralysis prevents attack") # Check if Tentacruel is paralyzed p2_active = p2.get_active_pokemon() is_paralyzed = p2_active and StatusCondition.PARALYZED in p2_active.status_conditions if is_paralyzed: print_action("Tentacruel is paralyzed - attempting attack...") attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player2", attack_action) tracker.check(not result.success, "Attack while paralyzed rejected") if not result.success: print_expected_failure(result.message) else: print_action("(Tentacool was not paralyzed from coin flip)") # Skip this check if no paralysis tracker.check(True, "Paralysis attack prevention (skipped - no paralysis)") print_step("5.3", "Paralysis prevents retreat") if is_paralyzed: print_action("Attempting to retreat while paralyzed...") retreat_action = RetreatAction( new_active_id="p2-grimer-bench", energy_to_discard=[], ) result = await engine.execute_action(game, "player2", retreat_action) tracker.check(not result.success, "Retreat while paralyzed rejected") if not result.success: print_expected_failure(result.message) else: tracker.check(True, "Paralysis retreat prevention (skipped - no paralysis)") print_step("5.4", "Paralysis clears at end of turn") if is_paralyzed: print_action("Ending paralyzed Pokemon's turn...") # Pass turn since paralyzed can't do anything pass_action = PassAction() await engine.execute_action(game, "player2", pass_action) # End turn - paralysis should clear end_result = engine.end_turn(game) # Check paralysis is gone p2_active = p2.get_active_pokemon() paralysis_removed = StatusCondition.PARALYZED not in p2_active.status_conditions tracker.check(paralysis_removed, "Paralysis removed at end of turn") if paralysis_removed: print_result("Paralysis wore off!") else: tracker.check(True, "Paralysis removal (skipped - no paralysis)") print_step("5.5", "Poison damage at end of turn") print_action("Setting up Grimer to apply poison...") # Let's set up a clean scenario for poison # Switch P2's active to Grimer manually for simplicity # Make sure it's player2's turn and they can act if game.current_player_id != "player2": engine.start_turn(game) # Reset game state for clean poison test # Swap Grimer to active p2_active = p2.get_active_pokemon() if p2_active: p2.active.remove(p2_active.instance_id) p2.bench.add(p2_active) grimer_card = p2.bench.get("p2-grimer-bench") if grimer_card: p2.bench.remove("p2-grimer-bench") p2.active.add(grimer_card) # Add energy to Grimer darkness_energy = CardInstance(instance_id="p2-darkness-test", definition_id="darkness-energy") grimer_active = p2.get_active_pokemon() grimer_active.attach_energy(darkness_energy) # Reset turn state so we can attach energy p2.reset_turn_state() game.phase = TurnPhase.MAIN print_action("Grimer uses Poison Gas on Pincurchin...") # Move to attack phase game.phase = TurnPhase.ATTACK attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player2", attack_action) if result.success: print_result(f"Attack result: {result.message}") # Check if Pincurchin is poisoned p1_active = p1.get_active_pokemon() is_poisoned = StatusCondition.POISONED in p1_active.status_conditions tracker.check(is_poisoned, "Poison applied to target") if is_poisoned: damage_before = p1_active.damage print_result(f"Pincurchin is poisoned! Current damage: {damage_before}") # End P2's turn to trigger poison damage on P1 end_result = engine.end_turn(game) print_result(f"Turn ended: {end_result.message}") # Note: Poison damage is applied at end of turn to the current player's Pokemon # But in the end_turn sequence, we advance to the next player # Actually, poison applies during between-turn effects # Let's check damage increased during the next player's end of turn # Start and complete P1's turn start_result = engine.start_turn(game) # Pass immediately to end turn pass_action = PassAction() await engine.execute_action(game, "player1", pass_action) # End turn - poison damage should apply end_result = engine.end_turn(game) damage_after = p1_active.damage poison_damage = damage_after - damage_before print_result(f"Poison dealt {poison_damage} damage") tracker.check(poison_damage == 10, f"Poison deals 10 damage (got {poison_damage})") else: print_expected_failure(f"Poison Gas attack failed: {result.message}") tracker.check(False, "Poison attack executed") if not wait_for_continue(): print("\nGoodbye!") return # ========================================================================= # PART 6: KNOCKOUT FLOW # ========================================================================= print_section("PART 6: KNOCKOUT FLOW") print_step("6.1", "Setting up for knockout") # Reset game state for knockout test # Set P1's turn and give Pincurchin enough energy game.current_player_id = "player1" engine.start_turn(game) p1_active = p1.get_active_pokemon() # Make sure we have enough energy while len(p1_active.attached_energy) < 2: e = CardInstance(instance_id=f"p1-lightning-ko-{len(p1_active.attached_energy)}", definition_id="lightning-energy") p1_active.attach_energy(e) # Put Tentacool back as P2's active with low HP p2_active = p2.get_active_pokemon() if p2_active: p2.active.remove(p2_active.instance_id) p2.bench.add(p2_active) # Get a Tentacool for the KO test tentacool_ko = CardInstance(instance_id="p2-tentacool-ko", definition_id="tentacool-001") # Give it damage so next hit will KO (60 HP, Thunder Shock does 50 with weakness) tentacool_ko.damage = 20 # 40 HP remaining, 50 damage = KO p2.active.add(tentacool_ko) p2_active = p2.get_active_pokemon() card_def = game.get_card_definition(p2_active.definition_id) print_action(f"Tentacool has {card_def.hp - p2_active.damage}/{card_def.hp} HP") print_action("Thunder Shock will deal 50 damage (30 base + 20 weakness)") print_step("6.2", "Executing knockout attack") # Move to attack phase game.phase = TurnPhase.ATTACK attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player1", attack_action) tracker.check(result.success, "Knockout attack executed") print_result(f"Attack result: {result.message}") # Check score increased print_step("6.3", "Points awarded") print_result(f"Player 1 score: {p1.score}") tracker.check(p1.score > 0, "Points awarded for knockout") print_step("6.4", "Forced action for new active") # Check if forced action was set up forced = game.get_current_forced_action() if forced: tracker.check(forced.action_type == "select_active", "Forced action to select new active") print_result(f"Forced action: {forced.reason}") print_step("6.5", "Invalid action during forced action") print_action("Attempting to attack during forced select_active...") # Try to attack when forced action is pending attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player2", attack_action) tracker.check(not result.success, "Non-forced action rejected") if not result.success: print_expected_failure(result.message) print_step("6.6", "Complete forced action") print_action("Player 2 selecting Grimer as new active...") # Find Grimer on bench grimer_id = None for card in p2.bench.cards: if card.definition_id == "grimer-001": grimer_id = card.instance_id break if grimer_id: select_action = SelectActiveAction(pokemon_id=grimer_id) result = await engine.execute_action(game, "player2", select_action) tracker.check(result.success, "New active selected successfully") if result.success: new_active = p2.get_active_pokemon() new_def = game.get_card_definition(new_active.definition_id) print_result(f"New active: {new_def.name}") else: # P2 might have no bench Pokemon left, which would be game over print_result("No forced action (opponent may have no benched Pokemon)") if result.win_result: print_result(f"Game ended: {result.win_result.reason}") tracker.check(True, "Win condition detected correctly") if not wait_for_continue(): print("\nGoodbye!") return # ========================================================================= # PART 7: WIN CONDITION # ========================================================================= print_section("PART 7: WIN CONDITION") # Check if game is already over if game.is_game_over(): print_step("7.1", "Game already ended") print_result(f"Winner: {game.winner_id}") print_result(f"Reason: {game.end_reason}") tracker.check(True, "Win condition properly detected") else: print_step("7.1", "Simulate win by setting score to 4") print_action(f"Current scores: P1={p1.score}, P2={p2.score}") print_action(f"Points to win: {game.rules.prizes.count}") # Clear any forced actions for clean test game.forced_actions.clear() # Manually set score to trigger win condition print_action("Setting P1 score to 3, then executing a knockout for the win...") # Set P1 score to 3 (one away from winning) p1.score = 3 print_result(f"P1 score set to: {p1.score}") # Make sure it's P1's turn with proper setup game.current_player_id = "player1" game.phase = TurnPhase.MAIN # Ensure P1 has an active Pokemon with energy p1_active = p1.get_active_pokemon() if not p1_active: p1_active = CardInstance(instance_id="p1-pincurchin-final", definition_id="pincurchin-001") p1.active.add(p1_active) while len(p1_active.attached_energy) < 2: e = CardInstance(instance_id=f"p1-lightning-final-{len(p1_active.attached_energy)}", definition_id="lightning-energy") p1_active.attach_energy(e) # Ensure P2 has a Tentacool (weak to Lightning) as active p2_active = p2.get_active_pokemon() if p2_active: # Remove current active and replace with Tentacool p2.active.remove(p2_active.instance_id) p2.bench.add(p2_active) # Create fresh Tentacool for predictable KO p2_active = CardInstance(instance_id="p2-tentacool-final", definition_id="tentacool-001") p2.active.add(p2_active) # Set damage so Thunder Shock (30 + 20 weakness = 50) will KO # Tentacool has 60 HP, so 20 damage = 40 HP left, 50 damage KOs p2_active.damage = 20 print_action("P2 Tentacool (weak to Lightning) has 40/60 HP remaining") # Execute knockout attack print_step("7.2", "Execute winning knockout") game.phase = TurnPhase.ATTACK attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player1", attack_action) print_result(f"Attack result: {result.message}") print_result(f"P1 final score: {p1.score}") win_triggered = False if result.win_result: print_result(f"Game ended: {result.win_result.reason}") tracker.check(True, "Win condition triggered correctly") win_triggered = True else: win_triggered = game.is_game_over() tracker.check(win_triggered, "Game ended after reaching 4 points") print_step("7.3", "Verify game over state") # Game should be over either via win_result or game.is_game_over() game_over = win_triggered or game.is_game_over() if game_over: tracker.check(True, "Game over detected") if game.winner_id: print_result(f"Winner: {game.winner_id}") print_result(f"End reason: {game.end_reason}") elif result.win_result: print_result(f"Winner: {result.win_result.winner_id}") print_result(f"End reason: {result.win_result.end_reason}") else: tracker.check(False, f"Game should be over but isn't (P1 score: {p1.score})") print_step("7.4", "Actions blocked after game over") # Use the game_over variable from Part 7 scope, or check again game_is_over = game.is_game_over() or (result and result.win_result is not None) if game_is_over: print_action("Attempting action after game over...") attack_action = AttackAction(attack_index=0) result = await engine.execute_action(game, "player1", attack_action) tracker.check(not result.success, "Action after game over rejected") if not result.success: print_expected_failure(result.message) if not wait_for_continue(): print("\nGoodbye!") return # ========================================================================= # SUMMARY # ========================================================================= tracker.summary() print(f"\n{info('Validation complete!')}") # ============================================================================= # ENTRY POINT # ============================================================================= if __name__ == "__main__": try: asyncio.run(run_validation()) except KeyboardInterrupt: print("\n\nValidation interrupted. Goodbye!") except Exception as e: print(error(f"\nError: {e}")) import traceback traceback.print_exc() raise