Replace hardcoded boolean flags with integer counters to support configurable per-turn limits from RulesConfig. This enables custom game modes with different rules (e.g., 2 energy attachments per turn, unlimited items, etc.). PlayerState changes: - energy_attached_this_turn -> energy_attachments_this_turn (int) - supporter_played_this_turn -> supporters_played_this_turn (int) - stadium_played_this_turn -> stadiums_played_this_turn (int) - retreated_this_turn -> retreats_this_turn (int) - Added items_played_this_turn (int) - Added can_play_stadium() and can_play_item() methods - Renamed reset_turn_flags() to reset_turn_state() Ability/CardInstance changes: - Ability.once_per_turn (bool) -> uses_per_turn (int|None) - CardInstance.ability_used_this_turn -> ability_uses_this_turn (int) - Added CardInstance.can_use_ability(ability) method All methods now properly compare counters against RulesConfig or Ability limits. 270 tests passing.
518 lines
19 KiB
Python
518 lines
19 KiB
Python
"""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 counters, 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_attachments_this_turn: Number of energy cards attached this turn.
|
|
supporters_played_this_turn: Number of Supporter cards played this turn.
|
|
stadiums_played_this_turn: Number of Stadium cards played this turn.
|
|
items_played_this_turn: Number of Item cards played this turn.
|
|
retreats_this_turn: Number of retreats performed 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 counters (reset at turn start)
|
|
# These are integers to support configurable rules (e.g., 2 energy per turn)
|
|
energy_attachments_this_turn: int = 0
|
|
supporters_played_this_turn: int = 0
|
|
stadiums_played_this_turn: int = 0
|
|
items_played_this_turn: int = 0
|
|
retreats_this_turn: int = 0
|
|
|
|
# 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.
|
|
|
|
Compares the number of energy attachments made this turn against the
|
|
rules.energy.attachments_per_turn limit.
|
|
|
|
Args:
|
|
rules: The RulesConfig governing this game.
|
|
|
|
Returns:
|
|
True if more energy can be attached this turn.
|
|
"""
|
|
return self.energy_attachments_this_turn < rules.energy.attachments_per_turn
|
|
|
|
def can_play_supporter(self, rules: RulesConfig) -> bool:
|
|
"""Check if this player can play a Supporter card.
|
|
|
|
Compares the number of Supporters played this turn against the
|
|
rules.trainer.supporters_per_turn limit.
|
|
|
|
Args:
|
|
rules: The RulesConfig governing this game.
|
|
|
|
Returns:
|
|
True if more Supporters can be played this turn.
|
|
"""
|
|
return self.supporters_played_this_turn < rules.trainer.supporters_per_turn
|
|
|
|
def can_play_stadium(self, rules: RulesConfig) -> bool:
|
|
"""Check if this player can play a Stadium card.
|
|
|
|
Compares the number of Stadiums played this turn against the
|
|
rules.trainer.stadiums_per_turn limit.
|
|
|
|
Args:
|
|
rules: The RulesConfig governing this game.
|
|
|
|
Returns:
|
|
True if more Stadiums can be played this turn.
|
|
"""
|
|
return self.stadiums_played_this_turn < rules.trainer.stadiums_per_turn
|
|
|
|
def can_play_item(self, rules: RulesConfig) -> bool:
|
|
"""Check if this player can play an Item card.
|
|
|
|
Compares the number of Items played this turn against the
|
|
rules.trainer.items_per_turn limit. If items_per_turn is None,
|
|
unlimited Items can be played.
|
|
|
|
Args:
|
|
rules: The RulesConfig governing this game.
|
|
|
|
Returns:
|
|
True if more Items can be played this turn.
|
|
"""
|
|
if rules.trainer.items_per_turn is None:
|
|
return True # Unlimited items
|
|
return self.items_played_this_turn < rules.trainer.items_per_turn
|
|
|
|
def can_retreat(self, rules: RulesConfig) -> bool:
|
|
"""Check if this player can retreat based on rules and turn state.
|
|
|
|
Compares the number of retreats performed this turn against the
|
|
rules.retreat.retreats_per_turn limit.
|
|
|
|
Args:
|
|
rules: The RulesConfig governing this game.
|
|
|
|
Returns:
|
|
True if more retreats are allowed this turn.
|
|
"""
|
|
return self.retreats_this_turn < rules.retreat.retreats_per_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_state(self) -> None:
|
|
"""Reset all per-turn counters. Called at the start of each turn.
|
|
|
|
Resets all action counters (energy attachments, trainer plays, retreats)
|
|
to zero, and resets ability usage on all Pokemon in play.
|
|
"""
|
|
self.energy_attachments_this_turn = 0
|
|
self.supporters_played_this_turn = 0
|
|
self.stadiums_played_this_turn = 0
|
|
self.items_played_this_turn = 0
|
|
self.retreats_this_turn = 0
|
|
|
|
# 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 state
|
|
self.get_current_player().reset_turn_state()
|
|
|
|
# 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
|