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:
parent
703bed07fb
commit
725c8ccc5c
@ -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",
|
||||||
|
]
|
||||||
|
|||||||
453
backend/app/core/models/game_state.py
Normal file
453
backend/app/core/models/game_state.py
Normal 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
|
||||||
610
backend/tests/core/conftest.py
Normal file
610
backend/tests/core/conftest.py
Normal 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()
|
||||||
936
backend/tests/core/test_models/test_game_state.py
Normal file
936
backend/tests/core/test_models/test_game_state.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user