mantimon-tcg/backend/references/engine_validation.py
Cal Corum c00ee87f25 Switch to testcontainers for automatic test container management
- Create tests/conftest.py with testcontainers for Postgres and Redis
- Auto-detect Docker Desktop socket and disable Ryuk for compatibility
- Update tests/db/conftest.py and tests/services/conftest.py to use shared fixtures
- Fix test_resolve_effect_logs_exceptions: logger was disabled by pytest
- Fix test_save_and_load_with_real_redis: use redis_url fixture
- Minor lint fix in engine_validation.py

Tests now auto-start containers on run - no need for `docker compose up`
All 1199 tests passing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 16:49:11 -06:00

1260 lines
45 KiB
Python

#!/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