Add GameState, PlayerState, Zone models and test fixtures

Core game state models:
- Zone: Card collection with deck operations (draw, shuffle, peek, etc.)
- PlayerState: All player zones, score, and per-turn action flags
- GameState: Complete game state with card registry, turn tracking, win conditions

Test fixtures (conftest.py):
- Sample card definitions: Pokemon (Pikachu, Raichu, Charizard, EX, V, VMAX)
- Trainer cards: Item (Potion), Supporter (Professor Oak), Stadium, Tool
- Energy cards: Basic and special energy
- Pre-configured game states: empty, mid-game, near-win scenarios
- Factory fixtures for CardInstance and SeededRandom

Tests: 55 new tests for game state models (259 total passing)

Note: GameState imported directly from game_state module to avoid
circular imports with config module.
This commit is contained in:
Cal Corum 2026-01-24 22:55:31 -06:00
parent 703bed07fb
commit 725c8ccc5c
4 changed files with 2081 additions and 0 deletions

View File

@ -5,4 +5,86 @@ This module contains all Pydantic models used throughout the game engine:
- card: CardDefinition (template) and CardInstance (in-game state) - card: CardDefinition (template) and CardInstance (in-game state)
- actions: Player action types as a discriminated union - actions: Player action types as a discriminated union
- game_state: GameState, PlayerState, and Zone models - game_state: GameState, PlayerState, and Zone models
Note: To avoid circular imports, game_state is not imported at module level.
Import directly from app.core.models.game_state when needed:
from app.core.models.game_state import GameState, PlayerState, Zone
Or import after config is already loaded:
from app.core.models import enums, card, actions
from app.core.models import game_state # Import after other modules
""" """
from app.core.models.actions import (
VALID_PHASES_FOR_ACTION,
Action,
AttachEnergyAction,
AttackAction,
EvolvePokemonAction,
PassAction,
PlayPokemonAction,
PlayTrainerAction,
ResignAction,
RetreatAction,
SelectActiveAction,
SelectPrizeAction,
UseAbilityAction,
parse_action,
)
from app.core.models.card import (
Ability,
Attack,
CardDefinition,
CardInstance,
WeaknessResistance,
)
from app.core.models.enums import (
ActionType,
CardType,
EnergyType,
GameEndReason,
PokemonStage,
PokemonVariant,
StatusCondition,
TrainerType,
TurnPhase,
)
# Note: GameState, PlayerState, Zone are imported from game_state module directly
# to avoid circular imports with app.core.config
__all__ = [
# Enums
"ActionType",
"CardType",
"EnergyType",
"GameEndReason",
"PokemonStage",
"PokemonVariant",
"StatusCondition",
"TrainerType",
"TurnPhase",
# Card models
"Ability",
"Attack",
"CardDefinition",
"CardInstance",
"WeaknessResistance",
# Action models
"Action",
"AttachEnergyAction",
"AttackAction",
"EvolvePokemonAction",
"PassAction",
"PlayPokemonAction",
"PlayTrainerAction",
"ResignAction",
"RetreatAction",
"SelectActiveAction",
"SelectPrizeAction",
"UseAbilityAction",
"VALID_PHASES_FOR_ACTION",
"parse_action",
]

View File

@ -0,0 +1,453 @@
"""Game state models for the Mantimon TCG game engine.
This module defines the complete game state hierarchy:
- Zone: A collection of cards (deck, hand, bench, etc.) with operations
- PlayerState: All zones and state for a single player
- GameState: Complete game state including both players, rules, and turn tracking
The GameState is designed to be self-contained - it includes the card registry
so that the game can be serialized/deserialized without external dependencies.
This supports both live multiplayer and offline/standalone play.
Usage:
# Create a new game
game = GameState(
game_id="match-123",
rules=RulesConfig(),
card_registry={card.id: card for card in cards},
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
)
# Access player zones
player = game.players["player1"]
card = player.hand.draw()
player.deck.shuffle(rng)
"""
from typing import Any
from pydantic import BaseModel, Field
from app.core.config import RulesConfig
from app.core.models.card import CardDefinition, CardInstance
from app.core.models.enums import GameEndReason, TurnPhase
from app.core.rng import RandomProvider
class Zone(BaseModel):
"""A collection of cards representing a game zone.
Zones are the fundamental building blocks of game state. Each zone
(deck, hand, bench, etc.) is a Zone instance with appropriate operations.
The zone stores CardInstance objects by their instance_id. The actual
CardDefinition data is looked up from the GameState.card_registry.
Attributes:
cards: List of CardInstance objects in this zone.
zone_type: Identifier for this zone type (for debugging/logging).
"""
cards: list[CardInstance] = Field(default_factory=list)
zone_type: str = "generic"
def __len__(self) -> int:
"""Return the number of cards in this zone."""
return len(self.cards)
def __bool__(self) -> bool:
"""Return True if the zone has any cards."""
return len(self.cards) > 0
def __contains__(self, instance_id: str) -> bool:
"""Check if a card with the given instance_id is in this zone."""
return any(card.instance_id == instance_id for card in self.cards)
def is_empty(self) -> bool:
"""Check if the zone has no cards."""
return len(self.cards) == 0
def add(self, card: CardInstance) -> None:
"""Add a card to this zone (appends to the end)."""
self.cards.append(card)
def add_to_top(self, card: CardInstance) -> None:
"""Add a card to the top of this zone (index 0, like top of deck)."""
self.cards.insert(0, card)
def add_to_bottom(self, card: CardInstance) -> None:
"""Add a card to the bottom of this zone (end of list)."""
self.cards.append(card)
def remove(self, instance_id: str) -> CardInstance | None:
"""Remove and return a card by instance_id, or None if not found."""
for i, card in enumerate(self.cards):
if card.instance_id == instance_id:
return self.cards.pop(i)
return None
def get(self, instance_id: str) -> CardInstance | None:
"""Get a card by instance_id without removing it."""
for card in self.cards:
if card.instance_id == instance_id:
return card
return None
def draw(self) -> CardInstance | None:
"""Remove and return the top card (index 0), or None if empty.
This is the standard "draw from deck" operation.
"""
if self.cards:
return self.cards.pop(0)
return None
def draw_bottom(self) -> CardInstance | None:
"""Remove and return the bottom card, or None if empty."""
if self.cards:
return self.cards.pop()
return None
def peek(self, count: int = 1) -> list[CardInstance]:
"""Look at the top N cards without removing them.
Args:
count: Number of cards to peek at.
Returns:
List of cards from the top (may be fewer if zone has less).
"""
return self.cards[:count]
def peek_bottom(self, count: int = 1) -> list[CardInstance]:
"""Look at the bottom N cards without removing them."""
if count >= len(self.cards):
return list(self.cards)
return self.cards[-count:]
def shuffle(self, rng: RandomProvider) -> None:
"""Shuffle the cards in this zone using the provided RNG.
Args:
rng: RandomProvider instance for deterministic shuffling.
"""
rng.shuffle(self.cards)
def clear(self) -> list[CardInstance]:
"""Remove and return all cards from this zone."""
cards = self.cards.copy()
self.cards.clear()
return cards
def get_all(self) -> list[CardInstance]:
"""Return a copy of all cards in this zone."""
return list(self.cards)
def find_by_definition(self, definition_id: str) -> list[CardInstance]:
"""Find all cards with a specific definition_id."""
return [card for card in self.cards if card.definition_id == definition_id]
def count(self) -> int:
"""Return the number of cards (alias for __len__)."""
return len(self.cards)
class PlayerState(BaseModel):
"""Complete state for a single player in a game.
Contains all zones (deck, hand, active, bench, etc.) and per-player
state like score, turn flags, and action tracking.
Attributes:
player_id: Unique identifier for this player.
deck: The player's draw deck.
hand: Cards in the player's hand.
active: The active Pokemon zone (0 or 1 card).
bench: Benched Pokemon (up to max_bench_size from rules).
discard: Discard pile.
prizes: Prize cards (hidden until taken, in point-based mode this is unused).
energy_deck: Separate energy deck (Pokemon Pocket style).
score: Points scored (knockouts).
energy_attached_this_turn: Whether energy was attached this turn.
supporter_played_this_turn: Whether a Supporter was played this turn.
stadium_played_this_turn: Whether a Stadium was played this turn.
retreated_this_turn: Whether the active Pokemon retreated this turn.
gx_attack_used: Whether this player has used their GX attack (once per game).
vstar_power_used: Whether this player has used their VSTAR power (once per game).
"""
player_id: str
# Zones
deck: Zone = Field(default_factory=lambda: Zone(zone_type="deck"))
hand: Zone = Field(default_factory=lambda: Zone(zone_type="hand"))
active: Zone = Field(default_factory=lambda: Zone(zone_type="active"))
bench: Zone = Field(default_factory=lambda: Zone(zone_type="bench"))
discard: Zone = Field(default_factory=lambda: Zone(zone_type="discard"))
prizes: Zone = Field(default_factory=lambda: Zone(zone_type="prizes"))
energy_deck: Zone = Field(default_factory=lambda: Zone(zone_type="energy_deck"))
energy_zone: Zone = Field(default_factory=lambda: Zone(zone_type="energy_zone"))
# Score tracking (point-based system)
score: int = 0
# Per-turn action flags (reset at turn start)
energy_attached_this_turn: bool = False
supporter_played_this_turn: bool = False
stadium_played_this_turn: bool = False
retreated_this_turn: bool = False
# Per-game flags
gx_attack_used: bool = False
vstar_power_used: bool = False
def get_active_pokemon(self) -> CardInstance | None:
"""Get the active Pokemon, or None if no active."""
if self.active.cards:
return self.active.cards[0]
return None
def has_active_pokemon(self) -> bool:
"""Check if this player has an active Pokemon."""
return len(self.active) > 0
def has_benched_pokemon(self) -> bool:
"""Check if this player has any benched Pokemon."""
return len(self.bench) > 0
def has_pokemon_in_play(self) -> bool:
"""Check if this player has any Pokemon in play (active or bench)."""
return self.has_active_pokemon() or self.has_benched_pokemon()
def get_all_pokemon_in_play(self) -> list[CardInstance]:
"""Get all Pokemon in play (active + bench)."""
pokemon = []
if self.active.cards:
pokemon.extend(self.active.cards)
pokemon.extend(self.bench.cards)
return pokemon
def can_attach_energy(self, rules: RulesConfig) -> bool:
"""Check if this player can attach energy based on rules and turn state."""
# In standard rules, only one attachment per turn
# This could be modified by card effects
return not self.energy_attached_this_turn
def can_play_supporter(self, rules: RulesConfig) -> bool:
"""Check if this player can play a Supporter card."""
return not self.supporter_played_this_turn
def can_retreat(self, rules: RulesConfig) -> bool:
"""Check if this player can retreat based on rules and turn state."""
if rules.retreat.retreats_per_turn == 0:
return False
# For now, we only support 1 retreat per turn
return not self.retreated_this_turn
def bench_space_available(self, rules: RulesConfig) -> int:
"""Return how many more Pokemon can be placed on the bench."""
return max(0, rules.bench.max_size - len(self.bench))
def can_bench_pokemon(self, rules: RulesConfig) -> bool:
"""Check if there's room on the bench for another Pokemon."""
return self.bench_space_available(rules) > 0
def reset_turn_flags(self) -> None:
"""Reset all per-turn flags. Called at the start of each turn."""
self.energy_attached_this_turn = False
self.supporter_played_this_turn = False
self.stadium_played_this_turn = False
self.retreated_this_turn = False
# Also reset ability usage on all Pokemon in play
for pokemon in self.get_all_pokemon_in_play():
pokemon.reset_turn_state()
class GameState(BaseModel):
"""Complete state of a game in progress.
This is the top-level model containing everything needed to represent
and resume a game. It's designed to be self-contained - the card_registry
holds all CardDefinitions used in this game, so the state can be
serialized and loaded without external dependencies.
Attributes:
game_id: Unique identifier for this game.
rules: The RulesConfig governing this game.
card_registry: Mapping of card definition IDs to CardDefinition objects.
players: Mapping of player IDs to PlayerState objects.
current_player_id: ID of the player whose turn it is.
turn_number: Current turn number (starts at 1).
phase: Current phase of the turn.
winner_id: ID of the winning player, if game has ended.
end_reason: Why the game ended, if it has ended.
stadium_in_play: The current Stadium card in play, if any.
turn_order: List of player IDs in turn order.
first_turn_completed: Whether the very first turn of the game is done.
action_log: Log of actions taken (for replays/debugging).
"""
game_id: str
rules: RulesConfig = Field(default_factory=RulesConfig)
card_registry: dict[str, CardDefinition] = Field(default_factory=dict)
players: dict[str, PlayerState] = Field(default_factory=dict)
# Turn tracking
current_player_id: str = ""
turn_number: int = 0
phase: TurnPhase = TurnPhase.SETUP
# Game end state
winner_id: str | None = None
end_reason: GameEndReason | None = None
# Shared game state
stadium_in_play: CardInstance | None = None
# Turn order (for 2+ player support)
turn_order: list[str] = Field(default_factory=list)
# First turn tracking
first_turn_completed: bool = False
# Optional action log for replays
action_log: list[dict[str, Any]] = Field(default_factory=list)
def get_current_player(self) -> PlayerState:
"""Get the PlayerState for the current player.
Raises:
KeyError: If current_player_id is not in players dict.
"""
return self.players[self.current_player_id]
def get_opponent_id(self, player_id: str) -> str:
"""Get the opponent's player ID (assumes 2-player game).
Args:
player_id: The player whose opponent we want.
Returns:
The opponent's player ID.
Raises:
ValueError: If player_id is not in the game or game has != 2 players.
"""
if len(self.players) != 2:
raise ValueError("get_opponent_id only works for 2-player games")
for pid in self.players:
if pid != player_id:
return pid
raise ValueError(f"Player {player_id} not found in game")
def get_opponent(self, player_id: str) -> PlayerState:
"""Get the PlayerState for a player's opponent (assumes 2-player game)."""
return self.players[self.get_opponent_id(player_id)]
def get_card_definition(self, definition_id: str) -> CardDefinition | None:
"""Look up a CardDefinition from the registry."""
return self.card_registry.get(definition_id)
def is_player_turn(self, player_id: str) -> bool:
"""Check if it's the specified player's turn."""
return self.current_player_id == player_id
def is_first_turn(self) -> bool:
"""Check if this is the very first turn of the game.
The first turn is turn 1, before first_turn_completed is set True.
"""
return self.turn_number == 1 and not self.first_turn_completed
def is_game_over(self) -> bool:
"""Check if the game has ended."""
return self.winner_id is not None
def get_player_count(self) -> int:
"""Return the number of players in this game."""
return len(self.players)
def advance_turn(self) -> None:
"""Advance to the next player's turn.
This handles:
- Setting first_turn_completed after turn 1
- Incrementing turn_number
- Switching current_player_id to the next player
- Resetting the new player's turn flags
- Setting phase to DRAW
"""
# Mark first turn as completed if we're ending turn 1
if self.turn_number == 1:
self.first_turn_completed = True
# Find next player in turn order
if self.turn_order:
current_index = self.turn_order.index(self.current_player_id)
next_index = (current_index + 1) % len(self.turn_order)
self.current_player_id = self.turn_order[next_index]
# Only increment turn number when we wrap back to first player
if next_index == 0:
self.turn_number += 1
else:
# Fallback for games without explicit turn order
self.turn_number += 1
# Reset the new player's turn flags
self.get_current_player().reset_turn_flags()
# Start at draw phase
self.phase = TurnPhase.DRAW
def set_winner(self, player_id: str, reason: GameEndReason) -> None:
"""Set the winner and end the game.
Args:
player_id: The winning player's ID.
reason: Why the game ended.
"""
self.winner_id = player_id
self.end_reason = reason
def log_action(self, action: dict[str, Any]) -> None:
"""Add an action to the action log for replay support."""
self.action_log.append(action)
def get_all_cards_in_play(self) -> list[CardInstance]:
"""Get all cards currently in play (all players' active and bench)."""
cards = []
for player in self.players.values():
cards.extend(player.get_all_pokemon_in_play())
return cards
def find_card_instance(self, instance_id: str) -> tuple[CardInstance | None, str | None]:
"""Find a CardInstance anywhere in the game.
Searches all zones of all players for the card.
Args:
instance_id: The instance_id to search for.
Returns:
Tuple of (CardInstance, zone_type) if found, (None, None) if not.
"""
for player in self.players.values():
for zone_name in [
"deck",
"hand",
"active",
"bench",
"discard",
"prizes",
"energy_deck",
]:
zone: Zone = getattr(player, zone_name)
card = zone.get(instance_id)
if card:
return card, zone_name
return None, None

View File

@ -0,0 +1,610 @@
"""Pytest fixtures for the core game engine tests.
This module provides reusable fixtures for testing the game engine:
- Sample card definitions (Pokemon, Trainer, Energy)
- Pre-configured game states
- Seeded RNG instances for deterministic testing
- Helper functions for creating test data
Usage:
def test_something(sample_pokemon, seeded_rng):
# sample_pokemon is a CardDefinition
# seeded_rng is a SeededRandom instance
pass
"""
import pytest
from app.core.config import RulesConfig
from app.core.models.card import (
Ability,
Attack,
CardDefinition,
CardInstance,
WeaknessResistance,
)
from app.core.models.enums import (
CardType,
EnergyType,
PokemonStage,
PokemonVariant,
TrainerType,
TurnPhase,
)
from app.core.models.game_state import GameState, PlayerState
from app.core.rng import SeededRandom
# ============================================================================
# RNG Fixtures
# ============================================================================
@pytest.fixture
def seeded_rng() -> SeededRandom:
"""Provide a SeededRandom instance with a fixed seed for deterministic tests.
The seed 42 is used consistently across tests for reproducibility.
"""
return SeededRandom(seed=42)
@pytest.fixture
def rng_factory():
"""Factory fixture to create SeededRandom instances with custom seeds.
Usage:
def test_something(rng_factory):
rng1 = rng_factory(seed=100)
rng2 = rng_factory(seed=200)
"""
def _create_rng(seed: int = 42) -> SeededRandom:
return SeededRandom(seed=seed)
return _create_rng
# ============================================================================
# Card Definition Fixtures - Pokemon
# ============================================================================
@pytest.fixture
def pikachu_def() -> CardDefinition:
"""Basic Lightning Pokemon - Pikachu.
A simple Basic Pokemon with one attack and standard stats.
Used as the canonical "basic Pokemon" in tests.
"""
return CardDefinition(
id="pikachu_base_001",
name="Pikachu",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
variant=PokemonVariant.NORMAL,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
attacks=[
Attack(
name="Thunder Shock",
cost=[EnergyType.LIGHTNING],
damage=20,
effect_id="may_paralyze",
effect_params={"chance": 0.5},
effect_description="Flip a coin. If heads, the Defending Pokemon is now Paralyzed.",
),
],
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
retreat_cost=1,
rarity="common",
set_id="base",
)
@pytest.fixture
def raichu_def() -> CardDefinition:
"""Stage 1 Lightning Pokemon - Raichu.
Evolves from Pikachu. Used for evolution tests.
"""
return CardDefinition(
id="raichu_base_001",
name="Raichu",
card_type=CardType.POKEMON,
stage=PokemonStage.STAGE_1,
variant=PokemonVariant.NORMAL,
evolves_from="Pikachu",
hp=90,
pokemon_type=EnergyType.LIGHTNING,
attacks=[
Attack(
name="Thunder",
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.COLORLESS],
damage=60,
),
],
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
retreat_cost=1,
rarity="rare",
set_id="base",
)
@pytest.fixture
def charizard_def() -> CardDefinition:
"""Stage 2 Fire Pokemon - Charizard.
Classic high-HP Stage 2 Pokemon. Evolves from Charmeleon.
"""
return CardDefinition(
id="charizard_base_001",
name="Charizard",
card_type=CardType.POKEMON,
stage=PokemonStage.STAGE_2,
variant=PokemonVariant.NORMAL,
evolves_from="Charmeleon",
hp=120,
pokemon_type=EnergyType.FIRE,
attacks=[
Attack(
name="Fire Spin",
cost=[EnergyType.FIRE, EnergyType.FIRE, EnergyType.FIRE, EnergyType.FIRE],
damage=100,
effect_id="discard_energy",
effect_params={"count": 2, "type": "fire"},
),
],
weakness=WeaknessResistance(energy_type=EnergyType.WATER, modifier=2),
resistance=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=-30),
retreat_cost=3,
rarity="rare_holo",
set_id="base",
)
@pytest.fixture
def mewtwo_ex_def() -> CardDefinition:
"""Basic EX Pokemon - Mewtwo EX.
High-HP Pokemon worth 2 knockout points.
"""
return CardDefinition(
id="mewtwo_ex_001",
name="Mewtwo EX",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
variant=PokemonVariant.EX,
hp=170,
pokemon_type=EnergyType.PSYCHIC,
attacks=[
Attack(
name="Psydrive",
cost=[EnergyType.PSYCHIC, EnergyType.COLORLESS],
damage=120,
effect_id="discard_energy",
effect_params={"count": 1, "type": "any"},
),
],
weakness=WeaknessResistance(energy_type=EnergyType.PSYCHIC, modifier=2),
retreat_cost=2,
rarity="ultra_rare",
set_id="ex_series",
)
@pytest.fixture
def pikachu_v_def() -> CardDefinition:
"""Basic V Pokemon - Pikachu V.
V Pokemon worth 2 knockout points.
"""
return CardDefinition(
id="pikachu_v_001",
name="Pikachu V",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
variant=PokemonVariant.V,
hp=190,
pokemon_type=EnergyType.LIGHTNING,
attacks=[
Attack(
name="Volt Tackle",
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.COLORLESS],
damage=210,
effect_id="self_damage",
effect_params={"amount": 30},
),
],
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
retreat_cost=2,
rarity="ultra_rare",
set_id="v_series",
)
@pytest.fixture
def pikachu_vmax_def() -> CardDefinition:
"""VMAX Pokemon - Pikachu VMAX.
Evolves from Pikachu V, worth 3 knockout points.
"""
return CardDefinition(
id="pikachu_vmax_001",
name="Pikachu VMAX",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC, # Stage is still Basic
variant=PokemonVariant.VMAX, # Variant indicates V evolution
evolves_from="Pikachu V",
hp=310,
pokemon_type=EnergyType.LIGHTNING,
attacks=[
Attack(
name="G-Max Volt Crash",
cost=[EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.LIGHTNING],
damage=270,
),
],
weakness=WeaknessResistance(energy_type=EnergyType.FIGHTING, modifier=2),
retreat_cost=3,
rarity="secret_rare",
set_id="vmax_series",
)
@pytest.fixture
def pokemon_with_ability_def() -> CardDefinition:
"""Pokemon with an Ability - Shaymin EX.
Used for ability testing.
"""
return CardDefinition(
id="shaymin_ex_001",
name="Shaymin EX",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
variant=PokemonVariant.EX,
hp=110,
pokemon_type=EnergyType.COLORLESS,
abilities=[
Ability(
name="Set Up",
effect_id="draw_until_hand_size",
effect_params={"count": 6},
effect_description="When you play this Pokemon from your hand to your Bench, "
"you may draw cards until you have 6 cards in your hand.",
once_per_turn=True,
),
],
attacks=[
Attack(
name="Sky Return",
cost=[EnergyType.COLORLESS, EnergyType.COLORLESS],
damage=30,
effect_id="return_to_hand",
),
],
retreat_cost=1,
rarity="ultra_rare",
set_id="ex_series",
)
# ============================================================================
# Card Definition Fixtures - Trainers
# ============================================================================
@pytest.fixture
def potion_def() -> CardDefinition:
"""Item card - Potion.
Basic healing item.
"""
return CardDefinition(
id="potion_base_001",
name="Potion",
card_type=CardType.TRAINER,
trainer_type=TrainerType.ITEM,
effect_id="heal",
effect_params={"amount": 30},
effect_description="Heal 30 damage from one of your Pokemon.",
rarity="common",
set_id="base",
)
@pytest.fixture
def professor_oak_def() -> CardDefinition:
"""Supporter card - Professor Oak.
Classic draw supporter.
"""
return CardDefinition(
id="professor_oak_001",
name="Professor Oak",
card_type=CardType.TRAINER,
trainer_type=TrainerType.SUPPORTER,
effect_id="discard_hand_draw",
effect_params={"draw_count": 7},
effect_description="Discard your hand and draw 7 cards.",
rarity="uncommon",
set_id="base",
)
@pytest.fixture
def pokemon_center_def() -> CardDefinition:
"""Stadium card - Pokemon Center.
Example stadium that stays in play.
"""
return CardDefinition(
id="pokemon_center_001",
name="Pokemon Center",
card_type=CardType.TRAINER,
trainer_type=TrainerType.STADIUM,
effect_id="stadium_heal_between_turns",
effect_params={"amount": 20},
effect_description="Between turns, heal 20 damage from each player's Active Pokemon.",
rarity="uncommon",
set_id="base",
)
@pytest.fixture
def choice_band_def() -> CardDefinition:
"""Tool card - Choice Band.
Damage-boosting tool.
"""
return CardDefinition(
id="choice_band_001",
name="Choice Band",
card_type=CardType.TRAINER,
trainer_type=TrainerType.TOOL,
effect_id="damage_boost_vs_ex_gx",
effect_params={"amount": 30},
effect_description="The attacks of the Pokemon this is attached to do 30 more damage "
"to your opponent's Active Pokemon-EX or Pokemon-GX.",
rarity="uncommon",
set_id="modern",
)
# ============================================================================
# Card Definition Fixtures - Energy
# ============================================================================
@pytest.fixture
def lightning_energy_def() -> CardDefinition:
"""Basic Lightning Energy."""
return CardDefinition(
id="lightning_energy_001",
name="Lightning Energy",
card_type=CardType.ENERGY,
energy_type=EnergyType.LIGHTNING,
energy_provides=[EnergyType.LIGHTNING],
rarity="common",
set_id="base",
)
@pytest.fixture
def fire_energy_def() -> CardDefinition:
"""Basic Fire Energy."""
return CardDefinition(
id="fire_energy_001",
name="Fire Energy",
card_type=CardType.ENERGY,
energy_type=EnergyType.FIRE,
energy_provides=[EnergyType.FIRE],
rarity="common",
set_id="base",
)
@pytest.fixture
def double_colorless_energy_def() -> CardDefinition:
"""Special Energy - Double Colorless Energy.
Provides 2 Colorless energy.
"""
return CardDefinition(
id="dce_001",
name="Double Colorless Energy",
card_type=CardType.ENERGY,
energy_provides=[EnergyType.COLORLESS, EnergyType.COLORLESS],
rarity="uncommon",
set_id="base",
)
# ============================================================================
# Card Registry Fixtures
# ============================================================================
@pytest.fixture
def basic_card_registry(
pikachu_def,
raichu_def,
charizard_def,
potion_def,
professor_oak_def,
lightning_energy_def,
fire_energy_def,
) -> dict[str, CardDefinition]:
"""A basic card registry with common test cards.
Includes: Pikachu, Raichu, Charizard, Potion, Professor Oak, basic energy.
"""
cards = [
pikachu_def,
raichu_def,
charizard_def,
potion_def,
professor_oak_def,
lightning_energy_def,
fire_energy_def,
]
return {card.id: card for card in cards}
# ============================================================================
# Card Instance Factory
# ============================================================================
@pytest.fixture
def card_instance_factory():
"""Factory fixture to create CardInstance objects.
Usage:
def test_something(card_instance_factory):
card = card_instance_factory("pikachu_base_001")
card_with_damage = card_instance_factory("pikachu_base_001", damage=30)
"""
_counter = [0]
def _create_instance(
definition_id: str,
instance_id: str | None = None,
damage: int = 0,
turn_played: int | None = None,
) -> CardInstance:
if instance_id is None:
_counter[0] += 1
instance_id = f"inst_{definition_id}_{_counter[0]}"
return CardInstance(
instance_id=instance_id,
definition_id=definition_id,
damage=damage,
turn_played=turn_played,
)
return _create_instance
# ============================================================================
# Game State Fixtures
# ============================================================================
@pytest.fixture
def empty_game_state(basic_card_registry) -> GameState:
"""An empty game state ready for setup.
Has two players with empty zones, in SETUP phase.
"""
return GameState(
game_id="test_game_001",
rules=RulesConfig(),
card_registry=basic_card_registry,
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=0,
phase=TurnPhase.SETUP,
)
@pytest.fixture
def mid_game_state(basic_card_registry, card_instance_factory) -> GameState:
"""A game state in the middle of play.
- Turn 3, player1's turn, MAIN phase
- Player1: Pikachu active, Raichu on bench, 3 cards in hand, score 1
- Player2: Charizard active, 4 cards in hand, score 0
- Both players have cards in deck and discard
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Player 1 setup
player1.active.add(card_instance_factory("pikachu_base_001", turn_played=1))
player1.bench.add(card_instance_factory("raichu_base_001", turn_played=2))
for _ in range(3):
player1.hand.add(card_instance_factory("lightning_energy_001"))
for _ in range(10):
player1.deck.add(card_instance_factory("pikachu_base_001"))
player1.discard.add(card_instance_factory("potion_base_001"))
player1.score = 1
# Player 2 setup
player2.active.add(card_instance_factory("charizard_base_001", turn_played=1, damage=40))
for _ in range(4):
player2.hand.add(card_instance_factory("fire_energy_001"))
for _ in range(8):
player2.deck.add(card_instance_factory("charizard_base_001"))
player2.discard.add(card_instance_factory("professor_oak_001"))
player2.score = 0
return GameState(
game_id="test_game_mid",
rules=RulesConfig(),
card_registry=basic_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=3,
phase=TurnPhase.MAIN,
first_turn_completed=True,
)
@pytest.fixture
def game_near_win_state(basic_card_registry, card_instance_factory) -> GameState:
"""A game state where player1 is about to win (3/4 points).
Used for testing win condition detection.
"""
player1 = PlayerState(player_id="player1")
player2 = PlayerState(player_id="player2")
# Player 1 setup - one knockout away from winning
player1.active.add(card_instance_factory("pikachu_base_001"))
player1.score = 3 # 4 points needed to win
# Player 2 setup - low HP Pokemon active
damaged_pikachu = card_instance_factory("pikachu_base_001", damage=50) # 10 HP remaining
player2.active.add(damaged_pikachu)
return GameState(
game_id="test_game_near_win",
rules=RulesConfig(), # Default: 4 points to win
card_registry=basic_card_registry,
players={"player1": player1, "player2": player2},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=5,
phase=TurnPhase.ATTACK,
first_turn_completed=True,
)
# ============================================================================
# Rules Config Fixtures
# ============================================================================
@pytest.fixture
def default_rules() -> RulesConfig:
"""Default Mantimon TCG rules.
40-card deck, 4 points to win, Pokemon Pocket-style energy.
"""
return RulesConfig()
@pytest.fixture
def standard_tcg_rules() -> RulesConfig:
"""Standard Pokemon TCG rules.
60-card deck, 6 prizes, no energy deck.
"""
return RulesConfig.standard_pokemon_tcg()

View File

@ -0,0 +1,936 @@
"""Tests for the game state models (Zone, PlayerState, GameState).
These tests verify:
1. Zone operations (add, remove, draw, shuffle, etc.)
2. PlayerState zone management and turn flags
3. GameState turn tracking and player management
4. JSON serialization round-trips
"""
from app.core.config import RulesConfig
from app.core.models.card import CardDefinition, CardInstance
from app.core.models.enums import CardType, EnergyType, GameEndReason, PokemonStage, TurnPhase
from app.core.models.game_state import GameState, PlayerState, Zone
from app.core.rng import SeededRandom
# ============================================================================
# Test Fixtures (local to this module, will move to conftest.py later)
# ============================================================================
def make_card_instance(instance_id: str, definition_id: str = "test_card") -> CardInstance:
"""Create a CardInstance for testing."""
return CardInstance(instance_id=instance_id, definition_id=definition_id)
def make_pokemon_definition(card_id: str, name: str = "Test Pokemon") -> CardDefinition:
"""Create a basic Pokemon CardDefinition for testing."""
return CardDefinition(
id=card_id,
name=name,
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=60,
pokemon_type=EnergyType.COLORLESS,
)
# ============================================================================
# Zone Tests
# ============================================================================
class TestZoneBasicOperations:
"""Tests for basic Zone operations."""
def test_zone_starts_empty(self) -> None:
"""
Verify a new Zone has no cards.
Zones should initialize as empty lists.
"""
zone = Zone()
assert len(zone) == 0
assert zone.is_empty()
assert not zone
def test_zone_add_card(self) -> None:
"""
Verify cards can be added to a Zone.
add() should append to the end of the zone.
"""
zone = Zone()
card = make_card_instance("card-1")
zone.add(card)
assert len(zone) == 1
assert not zone.is_empty()
assert zone
def test_zone_add_to_top(self) -> None:
"""
Verify add_to_top places cards at index 0.
This is useful for effects that put cards on top of deck.
"""
zone = Zone()
card1 = make_card_instance("card-1")
card2 = make_card_instance("card-2")
zone.add(card1)
zone.add_to_top(card2)
assert zone.cards[0].instance_id == "card-2"
assert zone.cards[1].instance_id == "card-1"
def test_zone_add_to_bottom(self) -> None:
"""
Verify add_to_bottom places cards at the end.
Alias for add(), but explicit about intent.
"""
zone = Zone()
card1 = make_card_instance("card-1")
card2 = make_card_instance("card-2")
zone.add(card1)
zone.add_to_bottom(card2)
assert zone.cards[0].instance_id == "card-1"
assert zone.cards[1].instance_id == "card-2"
def test_zone_contains(self) -> None:
"""
Verify __contains__ checks for instance_id.
Allows using 'in' operator with instance IDs.
"""
zone = Zone()
card = make_card_instance("card-1")
zone.add(card)
assert "card-1" in zone
assert "card-2" not in zone
def test_zone_get_card(self) -> None:
"""
Verify get() returns card without removing it.
"""
zone = Zone()
card = make_card_instance("card-1")
zone.add(card)
result = zone.get("card-1")
assert result is card
assert len(zone) == 1 # Card still in zone
def test_zone_get_nonexistent(self) -> None:
"""
Verify get() returns None for missing cards.
"""
zone = Zone()
result = zone.get("nonexistent")
assert result is None
def test_zone_remove_card(self) -> None:
"""
Verify remove() extracts and returns a card.
"""
zone = Zone()
card = make_card_instance("card-1")
zone.add(card)
result = zone.remove("card-1")
assert result is card
assert len(zone) == 0
def test_zone_remove_nonexistent(self) -> None:
"""
Verify remove() returns None for missing cards.
"""
zone = Zone()
result = zone.remove("nonexistent")
assert result is None
class TestZoneDeckOperations:
"""Tests for deck-like Zone operations (draw, peek, shuffle)."""
def test_zone_draw_from_top(self) -> None:
"""
Verify draw() removes and returns the top card (index 0).
Standard deck draw operation.
"""
zone = Zone()
card1 = make_card_instance("card-1")
card2 = make_card_instance("card-2")
zone.add(card1)
zone.add(card2)
drawn = zone.draw()
assert drawn is card1
assert len(zone) == 1
assert zone.cards[0] is card2
def test_zone_draw_from_empty(self) -> None:
"""
Verify draw() returns None when zone is empty.
This is used to detect deck-out condition.
"""
zone = Zone()
result = zone.draw()
assert result is None
def test_zone_draw_bottom(self) -> None:
"""
Verify draw_bottom() removes from the end.
Useful for some card effects.
"""
zone = Zone()
card1 = make_card_instance("card-1")
card2 = make_card_instance("card-2")
zone.add(card1)
zone.add(card2)
drawn = zone.draw_bottom()
assert drawn is card2
assert len(zone) == 1
def test_zone_peek(self) -> None:
"""
Verify peek() returns top cards without removing them.
"""
zone = Zone()
for i in range(5):
zone.add(make_card_instance(f"card-{i}"))
peeked = zone.peek(3)
assert len(peeked) == 3
assert peeked[0].instance_id == "card-0"
assert len(zone) == 5 # Cards still in zone
def test_zone_peek_more_than_available(self) -> None:
"""
Verify peek() returns all cards if count exceeds zone size.
"""
zone = Zone()
zone.add(make_card_instance("card-1"))
peeked = zone.peek(10)
assert len(peeked) == 1
def test_zone_peek_bottom(self) -> None:
"""
Verify peek_bottom() returns cards from the end.
"""
zone = Zone()
for i in range(5):
zone.add(make_card_instance(f"card-{i}"))
peeked = zone.peek_bottom(2)
assert len(peeked) == 2
assert peeked[0].instance_id == "card-3"
assert peeked[1].instance_id == "card-4"
def test_zone_shuffle_deterministic(self) -> None:
"""
Verify shuffle() produces deterministic results with SeededRandom.
Same seed should produce same shuffle order.
"""
zone1 = Zone()
zone2 = Zone()
for i in range(10):
zone1.add(make_card_instance(f"card-{i}"))
zone2.add(make_card_instance(f"card-{i}"))
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
zone1.shuffle(rng1)
zone2.shuffle(rng2)
order1 = [c.instance_id for c in zone1.cards]
order2 = [c.instance_id for c in zone2.cards]
assert order1 == order2
def test_zone_shuffle_changes_order(self) -> None:
"""
Verify shuffle() changes the order of cards.
With 10 cards, probability of unchanged order is negligible.
"""
zone = Zone()
for i in range(10):
zone.add(make_card_instance(f"card-{i}"))
original_order = [c.instance_id for c in zone.cards]
rng = SeededRandom(seed=12345)
zone.shuffle(rng)
new_order = [c.instance_id for c in zone.cards]
assert original_order != new_order
def test_zone_clear(self) -> None:
"""
Verify clear() removes and returns all cards.
"""
zone = Zone()
for i in range(3):
zone.add(make_card_instance(f"card-{i}"))
cleared = zone.clear()
assert len(cleared) == 3
assert zone.is_empty()
def test_zone_get_all(self) -> None:
"""
Verify get_all() returns a copy of all cards.
"""
zone = Zone()
for i in range(3):
zone.add(make_card_instance(f"card-{i}"))
all_cards = zone.get_all()
assert len(all_cards) == 3
# Modifying the returned list shouldn't affect zone
all_cards.pop()
assert len(zone) == 3
class TestZoneSearch:
"""Tests for Zone search operations."""
def test_zone_find_by_definition(self) -> None:
"""
Verify find_by_definition() returns cards with matching definition_id.
"""
zone = Zone()
zone.add(make_card_instance("inst-1", definition_id="pikachu"))
zone.add(make_card_instance("inst-2", definition_id="charmander"))
zone.add(make_card_instance("inst-3", definition_id="pikachu"))
found = zone.find_by_definition("pikachu")
assert len(found) == 2
assert all(c.definition_id == "pikachu" for c in found)
def test_zone_find_by_definition_none_found(self) -> None:
"""
Verify find_by_definition() returns empty list when no matches.
"""
zone = Zone()
zone.add(make_card_instance("inst-1", definition_id="pikachu"))
found = zone.find_by_definition("mewtwo")
assert found == []
def test_zone_count(self) -> None:
"""
Verify count() returns the number of cards.
Alias for __len__().
"""
zone = Zone()
assert zone.count() == 0
zone.add(make_card_instance("card-1"))
zone.add(make_card_instance("card-2"))
assert zone.count() == 2
# ============================================================================
# PlayerState Tests
# ============================================================================
class TestPlayerStateCreation:
"""Tests for PlayerState creation and initialization."""
def test_player_state_defaults(self) -> None:
"""
Verify PlayerState initializes with empty zones and default flags.
"""
player = PlayerState(player_id="player1")
assert player.player_id == "player1"
assert player.deck.is_empty()
assert player.hand.is_empty()
assert player.active.is_empty()
assert player.bench.is_empty()
assert player.discard.is_empty()
assert player.prizes.is_empty()
assert player.score == 0
assert not player.energy_attached_this_turn
assert not player.supporter_played_this_turn
assert not player.gx_attack_used
def test_player_state_zone_types(self) -> None:
"""
Verify zones are labeled with their zone_type.
"""
player = PlayerState(player_id="player1")
assert player.deck.zone_type == "deck"
assert player.hand.zone_type == "hand"
assert player.active.zone_type == "active"
assert player.bench.zone_type == "bench"
class TestPlayerStatePokemonTracking:
"""Tests for PlayerState Pokemon-related methods."""
def test_get_active_pokemon(self) -> None:
"""
Verify get_active_pokemon() returns the active Pokemon.
"""
player = PlayerState(player_id="player1")
card = make_card_instance("pokemon-1")
player.active.add(card)
active = player.get_active_pokemon()
assert active is card
def test_get_active_pokemon_none(self) -> None:
"""
Verify get_active_pokemon() returns None when no active.
"""
player = PlayerState(player_id="player1")
assert player.get_active_pokemon() is None
def test_has_active_pokemon(self) -> None:
"""
Verify has_active_pokemon() checks active zone.
"""
player = PlayerState(player_id="player1")
assert not player.has_active_pokemon()
player.active.add(make_card_instance("pokemon-1"))
assert player.has_active_pokemon()
def test_has_benched_pokemon(self) -> None:
"""
Verify has_benched_pokemon() checks bench zone.
"""
player = PlayerState(player_id="player1")
assert not player.has_benched_pokemon()
player.bench.add(make_card_instance("pokemon-1"))
assert player.has_benched_pokemon()
def test_has_pokemon_in_play(self) -> None:
"""
Verify has_pokemon_in_play() checks both active and bench.
"""
player = PlayerState(player_id="player1")
assert not player.has_pokemon_in_play()
# Only bench
player.bench.add(make_card_instance("pokemon-1"))
assert player.has_pokemon_in_play()
# Clear and add only active
player.bench.clear()
player.active.add(make_card_instance("pokemon-2"))
assert player.has_pokemon_in_play()
def test_get_all_pokemon_in_play(self) -> None:
"""
Verify get_all_pokemon_in_play() returns active + bench.
"""
player = PlayerState(player_id="player1")
active = make_card_instance("active-1")
bench1 = make_card_instance("bench-1")
bench2 = make_card_instance("bench-2")
player.active.add(active)
player.bench.add(bench1)
player.bench.add(bench2)
all_pokemon = player.get_all_pokemon_in_play()
assert len(all_pokemon) == 3
assert active in all_pokemon
assert bench1 in all_pokemon
assert bench2 in all_pokemon
class TestPlayerStateTurnActions:
"""Tests for PlayerState turn action tracking."""
def test_can_attach_energy(self) -> None:
"""
Verify can_attach_energy() respects turn flag.
"""
player = PlayerState(player_id="player1")
rules = RulesConfig()
assert player.can_attach_energy(rules)
player.energy_attached_this_turn = True
assert not player.can_attach_energy(rules)
def test_can_play_supporter(self) -> None:
"""
Verify can_play_supporter() respects turn flag.
"""
player = PlayerState(player_id="player1")
rules = RulesConfig()
assert player.can_play_supporter(rules)
player.supporter_played_this_turn = True
assert not player.can_play_supporter(rules)
def test_can_retreat(self) -> None:
"""
Verify can_retreat() respects turn flag and rules.
"""
player = PlayerState(player_id="player1")
rules = RulesConfig()
assert player.can_retreat(rules)
player.retreated_this_turn = True
assert not player.can_retreat(rules)
def test_bench_space_available(self) -> None:
"""
Verify bench_space_available() calculates remaining bench slots.
"""
player = PlayerState(player_id="player1")
rules = RulesConfig() # Default max_bench = 5
assert player.bench_space_available(rules) == 5
for i in range(3):
player.bench.add(make_card_instance(f"pokemon-{i}"))
assert player.bench_space_available(rules) == 2
def test_can_bench_pokemon(self) -> None:
"""
Verify can_bench_pokemon() checks bench space.
"""
player = PlayerState(player_id="player1")
rules = RulesConfig()
assert player.can_bench_pokemon(rules)
# Fill bench
for i in range(5):
player.bench.add(make_card_instance(f"pokemon-{i}"))
assert not player.can_bench_pokemon(rules)
def test_reset_turn_flags(self) -> None:
"""
Verify reset_turn_flags() clears all per-turn flags.
"""
player = PlayerState(player_id="player1")
player.energy_attached_this_turn = True
player.supporter_played_this_turn = True
player.stadium_played_this_turn = True
player.retreated_this_turn = True
# Add a pokemon with ability used
pokemon = make_card_instance("pokemon-1")
pokemon.ability_used_this_turn = True
player.active.add(pokemon)
player.reset_turn_flags()
assert not player.energy_attached_this_turn
assert not player.supporter_played_this_turn
assert not player.stadium_played_this_turn
assert not player.retreated_this_turn
assert not pokemon.ability_used_this_turn
# ============================================================================
# GameState Tests
# ============================================================================
class TestGameStateCreation:
"""Tests for GameState creation and initialization."""
def test_game_state_minimal(self) -> None:
"""
Verify GameState can be created with minimal fields.
"""
game = GameState(game_id="game-1")
assert game.game_id == "game-1"
assert game.turn_number == 0
assert game.phase == TurnPhase.SETUP
assert game.winner_id is None
assert not game.is_game_over()
def test_game_state_with_players(self) -> None:
"""
Verify GameState can be created with players.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player1",
)
assert game.get_player_count() == 2
assert "player1" in game.players
assert "player2" in game.players
def test_game_state_with_card_registry(self) -> None:
"""
Verify card_registry stores CardDefinitions.
"""
pikachu = make_pokemon_definition("pikachu-001", "Pikachu")
charmander = make_pokemon_definition("charmander-001", "Charmander")
game = GameState(
game_id="game-1",
card_registry={
"pikachu-001": pikachu,
"charmander-001": charmander,
},
)
assert game.get_card_definition("pikachu-001") is pikachu
assert game.get_card_definition("charmander-001") is charmander
assert game.get_card_definition("nonexistent") is None
class TestGameStatePlayerAccess:
"""Tests for GameState player access methods."""
def test_get_current_player(self) -> None:
"""
Verify get_current_player() returns the active player.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
current_player_id="player1",
)
current = game.get_current_player()
assert current.player_id == "player1"
def test_get_opponent_id(self) -> None:
"""
Verify get_opponent_id() returns the other player's ID.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
)
assert game.get_opponent_id("player1") == "player2"
assert game.get_opponent_id("player2") == "player1"
def test_get_opponent(self) -> None:
"""
Verify get_opponent() returns the opponent's PlayerState.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
)
opponent = game.get_opponent("player1")
assert opponent.player_id == "player2"
def test_is_player_turn(self) -> None:
"""
Verify is_player_turn() checks current player.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
current_player_id="player1",
)
assert game.is_player_turn("player1")
assert not game.is_player_turn("player2")
class TestGameStateTurnManagement:
"""Tests for GameState turn management."""
def test_is_first_turn(self) -> None:
"""
Verify is_first_turn() returns True only on turn 1.
"""
game = GameState(game_id="game-1", turn_number=1)
assert game.is_first_turn()
game.first_turn_completed = True
assert not game.is_first_turn()
game.turn_number = 2
assert not game.is_first_turn()
def test_advance_turn(self) -> None:
"""
Verify advance_turn() switches players and updates state.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=1,
phase=TurnPhase.END,
)
# Set some turn flags on player 2 to verify reset
game.players["player2"].energy_attached_this_turn = True
game.advance_turn()
assert game.current_player_id == "player2"
assert game.first_turn_completed is True
assert game.phase == TurnPhase.DRAW
assert not game.players["player2"].energy_attached_this_turn
def test_advance_turn_wraps_around(self) -> None:
"""
Verify advance_turn() wraps back to first player and increments turn.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player2",
turn_number=1,
first_turn_completed=True,
)
game.advance_turn()
assert game.current_player_id == "player1"
assert game.turn_number == 2
class TestGameStateWinConditions:
"""Tests for GameState win condition tracking."""
def test_set_winner(self) -> None:
"""
Verify set_winner() ends the game.
"""
game = GameState(game_id="game-1")
game.set_winner("player1", GameEndReason.PRIZES_TAKEN)
assert game.is_game_over()
assert game.winner_id == "player1"
assert game.end_reason == GameEndReason.PRIZES_TAKEN
def test_is_game_over(self) -> None:
"""
Verify is_game_over() checks winner_id.
"""
game = GameState(game_id="game-1")
assert not game.is_game_over()
game.winner_id = "player1"
assert game.is_game_over()
class TestGameStateCardSearch:
"""Tests for GameState card search operations."""
def test_find_card_instance(self) -> None:
"""
Verify find_card_instance() searches all zones.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
},
)
card = make_card_instance("test-card")
game.players["player1"].hand.add(card)
found, zone = game.find_card_instance("test-card")
assert found is card
assert zone == "hand"
def test_find_card_instance_not_found(self) -> None:
"""
Verify find_card_instance() returns None for missing cards.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
},
)
found, zone = game.find_card_instance("nonexistent")
assert found is None
assert zone is None
def test_get_all_cards_in_play(self) -> None:
"""
Verify get_all_cards_in_play() returns all active/bench Pokemon.
"""
game = GameState(
game_id="game-1",
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
)
p1_active = make_card_instance("p1-active")
p1_bench = make_card_instance("p1-bench")
p2_active = make_card_instance("p2-active")
game.players["player1"].active.add(p1_active)
game.players["player1"].bench.add(p1_bench)
game.players["player2"].active.add(p2_active)
all_in_play = game.get_all_cards_in_play()
assert len(all_in_play) == 3
assert p1_active in all_in_play
assert p1_bench in all_in_play
assert p2_active in all_in_play
class TestGameStateActionLog:
"""Tests for GameState action logging."""
def test_log_action(self) -> None:
"""
Verify log_action() appends to action_log.
"""
game = GameState(game_id="game-1")
game.log_action({"type": "play_pokemon", "card_id": "pikachu-1"})
game.log_action({"type": "attack", "attack_index": 0})
assert len(game.action_log) == 2
assert game.action_log[0]["type"] == "play_pokemon"
assert game.action_log[1]["type"] == "attack"
class TestGameStateJsonRoundTrip:
"""Tests for JSON serialization of GameState."""
def test_zone_round_trip(self) -> None:
"""
Verify Zone serializes and deserializes correctly.
"""
zone = Zone(zone_type="hand")
zone.add(make_card_instance("card-1", "pikachu"))
zone.add(make_card_instance("card-2", "charmander"))
json_str = zone.model_dump_json()
restored = Zone.model_validate_json(json_str)
assert restored.zone_type == "hand"
assert len(restored) == 2
assert restored.cards[0].instance_id == "card-1"
def test_player_state_round_trip(self) -> None:
"""
Verify PlayerState serializes and deserializes correctly.
"""
player = PlayerState(player_id="player1")
player.hand.add(make_card_instance("hand-1"))
player.score = 3
player.energy_attached_this_turn = True
json_str = player.model_dump_json()
restored = PlayerState.model_validate_json(json_str)
assert restored.player_id == "player1"
assert len(restored.hand) == 1
assert restored.score == 3
assert restored.energy_attached_this_turn is True
def test_game_state_round_trip(self) -> None:
"""
Verify complete GameState serializes and deserializes correctly.
This is critical for saving/loading games and sending state to clients.
"""
pikachu = make_pokemon_definition("pikachu-001", "Pikachu")
game = GameState(
game_id="game-123",
rules=RulesConfig(),
card_registry={"pikachu-001": pikachu},
players={
"player1": PlayerState(player_id="player1"),
"player2": PlayerState(player_id="player2"),
},
turn_order=["player1", "player2"],
current_player_id="player1",
turn_number=3,
phase=TurnPhase.MAIN,
)
# Add some cards
card = make_card_instance("inst-1", "pikachu-001")
game.players["player1"].active.add(card)
json_str = game.model_dump_json()
restored = GameState.model_validate_json(json_str)
assert restored.game_id == "game-123"
assert restored.turn_number == 3
assert restored.phase == TurnPhase.MAIN
assert "pikachu-001" in restored.card_registry
assert len(restored.players["player1"].active) == 1