From 325f1e8af5ea7b0467f284f6f7672f0e7e1b90db Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 24 Jan 2026 23:16:37 -0600 Subject: [PATCH] Refactor turn action tracking from booleans to counters for RulesConfig support 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. --- backend/app/core/models/card.py | 31 ++- backend/app/core/models/game_state.py | 122 +++++++--- backend/tests/core/test_models/test_card.py | 88 ++++++- .../tests/core/test_models/test_game_state.py | 225 +++++++++++++++--- 4 files changed, 387 insertions(+), 79 deletions(-) diff --git a/backend/app/core/models/card.py b/backend/app/core/models/card.py index b38944d..d4e0a0e 100644 --- a/backend/app/core/models/card.py +++ b/backend/app/core/models/card.py @@ -78,15 +78,15 @@ class Ability(BaseModel): effect_id: Reference to an effect handler. effect_params: Parameters passed to the effect handler. effect_description: Human-readable description of the ability. - once_per_turn: If True, can only be used once per turn per Pokemon. - activation_phase: When this ability can be activated (default: main phase). + uses_per_turn: Maximum uses per turn. None means unlimited uses. + Default is 1 (once per turn), which matches standard Pokemon TCG rules. """ name: str effect_id: str effect_params: dict[str, Any] = Field(default_factory=dict) effect_description: str | None = None - once_per_turn: bool = True + uses_per_turn: int | None = 1 # None = unlimited, 1 = once per turn (default) class WeaknessResistance(BaseModel): @@ -239,7 +239,8 @@ class CardInstance(BaseModel): attached_energy: List of CardInstance IDs for attached energy cards. attached_tools: List of CardInstance IDs for attached tool cards. status_conditions: Active status conditions on this Pokemon. - ability_used_this_turn: Whether an ability was used this turn. + ability_uses_this_turn: Number of times abilities have been used this turn. + Compared against Ability.uses_per_turn to determine if more uses allowed. evolved_from_instance_id: The CardInstance this evolved from. turn_played: The turn number when this card was played/evolved. Used for evolution timing rules. @@ -255,7 +256,7 @@ class CardInstance(BaseModel): attached_energy: list[str] = Field(default_factory=list) attached_tools: list[str] = Field(default_factory=list) status_conditions: list[StatusCondition] = Field(default_factory=list) - ability_used_this_turn: bool = False + ability_uses_this_turn: int = 0 # Evolution tracking evolved_from_instance_id: str | None = None @@ -314,8 +315,24 @@ class CardInstance(BaseModel): self.status_conditions.clear() def reset_turn_state(self) -> None: - """Reset per-turn state flags. Called at the start of each turn.""" - self.ability_used_this_turn = False + """Reset per-turn state counters. Called at the start of each turn.""" + self.ability_uses_this_turn = 0 + + def can_use_ability(self, ability: Ability) -> bool: + """Check if this Pokemon can use the given ability. + + Compares ability_uses_this_turn against the ability's uses_per_turn limit. + If uses_per_turn is None, the ability has no usage limit. + + Args: + ability: The Ability to check. + + Returns: + True if the ability can be used (hasn't hit its per-turn limit). + """ + if ability.uses_per_turn is None: + return True # Unlimited uses + return self.ability_uses_this_turn < ability.uses_per_turn def can_evolve_this_turn(self, current_turn: int) -> bool: """Check if this Pokemon can evolve this turn. diff --git a/backend/app/core/models/game_state.py b/backend/app/core/models/game_state.py index e8772be..e2c23d7 100644 --- a/backend/app/core/models/game_state.py +++ b/backend/app/core/models/game_state.py @@ -159,7 +159,7 @@ 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. + state like score, turn counters, and action tracking. Attributes: player_id: Unique identifier for this player. @@ -171,10 +171,11 @@ class PlayerState(BaseModel): 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. + 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). """ @@ -194,11 +195,13 @@ class PlayerState(BaseModel): # 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-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 @@ -231,21 +234,77 @@ class PlayerState(BaseModel): 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 + """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.""" - return not self.supporter_played_this_turn + """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.""" - if rules.retreat.retreats_per_turn == 0: - return False - # For now, we only support 1 retreat per turn - return not self.retreated_this_turn + """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.""" @@ -255,12 +314,17 @@ class PlayerState(BaseModel): """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 + 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(): @@ -398,8 +462,8 @@ class GameState(BaseModel): # 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() + # Reset the new player's turn state + self.get_current_player().reset_turn_state() # Start at draw phase self.phase = TurnPhase.DRAW diff --git a/backend/tests/core/test_models/test_card.py b/backend/tests/core/test_models/test_card.py index 87e666c..dc6fa9e 100644 --- a/backend/tests/core/test_models/test_card.py +++ b/backend/tests/core/test_models/test_card.py @@ -107,7 +107,9 @@ class TestAbility: def test_basic_ability(self) -> None: """ - Verify an ability can be created. + Verify an ability can be created with default uses_per_turn. + + Default is 1 use per turn, matching standard Pokemon TCG rules. """ ability = Ability( name="Rain Dish", @@ -117,19 +119,36 @@ class TestAbility: assert ability.name == "Rain Dish" assert ability.effect_id == "heal_between_turns" - assert ability.once_per_turn is True # Default + assert ability.uses_per_turn == 1 # Default: once per turn - def test_ability_multiple_use_per_turn(self) -> None: + def test_ability_unlimited_uses(self) -> None: """ - Verify abilities can be marked as usable multiple times per turn. + Verify abilities can be configured for unlimited uses per turn. + + Setting uses_per_turn to None allows the ability to be used + any number of times per turn. """ ability = Ability( name="Energy Transfer", effect_id="move_energy", - once_per_turn=False, + uses_per_turn=None, ) - assert ability.once_per_turn is False + assert ability.uses_per_turn is None + + def test_ability_multiple_uses_per_turn(self) -> None: + """ + Verify abilities can have custom uses_per_turn limit. + + Some abilities may be usable 2 or more times per turn. + """ + ability = Ability( + name="Double Draw", + effect_id="draw_cards", + uses_per_turn=2, + ) + + assert ability.uses_per_turn == 2 class TestWeaknessResistance: @@ -979,14 +998,65 @@ class TestCardInstanceTurnState: def test_reset_turn_state(self) -> None: """ - Verify reset_turn_state clears ability usage flag. + Verify reset_turn_state resets ability usage counter to 0. + + This is called at the start of each turn to allow abilities + to be used again. """ instance = CardInstance(instance_id="uuid", definition_id="pikachu") - instance.ability_used_this_turn = True + instance.ability_uses_this_turn = 3 instance.reset_turn_state() - assert instance.ability_used_this_turn is False + assert instance.ability_uses_this_turn == 0 + + def test_can_use_ability_once_per_turn(self) -> None: + """ + Verify can_use_ability respects uses_per_turn = 1 (default). + + Standard Pokemon TCG abilities can be used once per turn. + """ + instance = CardInstance(instance_id="uuid", definition_id="pikachu") + ability = Ability(name="Static", effect_id="static_paralysis") # Default: uses_per_turn=1 + + assert instance.can_use_ability(ability) + + instance.ability_uses_this_turn = 1 + assert not instance.can_use_ability(ability) + + def test_can_use_ability_multiple_per_turn(self) -> None: + """ + Verify can_use_ability respects custom uses_per_turn limit. + + Some abilities can be used multiple times per turn. + """ + instance = CardInstance(instance_id="uuid", definition_id="pikachu") + ability = Ability(name="Double Draw", effect_id="draw_cards", uses_per_turn=2) + + assert instance.can_use_ability(ability) + + instance.ability_uses_this_turn = 1 + assert instance.can_use_ability(ability) + + instance.ability_uses_this_turn = 2 + assert not instance.can_use_ability(ability) + + def test_can_use_ability_unlimited(self) -> None: + """ + Verify can_use_ability allows unlimited uses when uses_per_turn is None. + + Some abilities have no per-turn usage limit. + """ + instance = CardInstance(instance_id="uuid", definition_id="pikachu") + ability = Ability(name="Energy Transfer", effect_id="move_energy", uses_per_turn=None) + + assert instance.can_use_ability(ability) + + instance.ability_uses_this_turn = 10 + assert instance.can_use_ability(ability) + + instance.ability_uses_this_turn = 100 + assert instance.can_use_ability(ability) class TestCardInstanceJsonRoundTrip: diff --git a/backend/tests/core/test_models/test_game_state.py b/backend/tests/core/test_models/test_game_state.py index 035eb46..87b5db9 100644 --- a/backend/tests/core/test_models/test_game_state.py +++ b/backend/tests/core/test_models/test_game_state.py @@ -365,7 +365,10 @@ class TestPlayerStateCreation: def test_player_state_defaults(self) -> None: """ - Verify PlayerState initializes with empty zones and default flags. + Verify PlayerState initializes with empty zones and default counters. + + All per-turn counters should start at 0, and per-game flags should + start as False. """ player = PlayerState(player_id="player1") @@ -377,9 +380,15 @@ class TestPlayerStateCreation: 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 + # Per-turn counters start at 0 + assert player.energy_attachments_this_turn == 0 + assert player.supporters_played_this_turn == 0 + assert player.stadiums_played_this_turn == 0 + assert player.items_played_this_turn == 0 + assert player.retreats_this_turn == 0 + # Per-game flags start as False assert not player.gx_attack_used + assert not player.vstar_power_used def test_player_state_zone_types(self) -> None: """ @@ -475,40 +484,171 @@ class TestPlayerStatePokemonTracking: class TestPlayerStateTurnActions: """Tests for PlayerState turn action tracking.""" - def test_can_attach_energy(self) -> None: + def test_can_attach_energy_default_rules(self) -> None: """ - Verify can_attach_energy() respects turn flag. + Verify can_attach_energy() respects turn counter with default rules. + + Default rules allow 1 energy attachment per turn. Counter starts at 0, + so first attachment is allowed, second is not. """ player = PlayerState(player_id="player1") - rules = RulesConfig() + rules = RulesConfig() # Default: attachments_per_turn = 1 assert player.can_attach_energy(rules) - player.energy_attached_this_turn = True + player.energy_attachments_this_turn = 1 assert not player.can_attach_energy(rules) - def test_can_play_supporter(self) -> None: + def test_can_attach_energy_multiple_per_turn(self) -> None: """ - Verify can_play_supporter() respects turn flag. + Verify can_attach_energy() respects custom attachments_per_turn rule. + + Some game modes allow multiple energy attachments per turn. This tests + that the counter-based approach works with configurable limits. """ player = PlayerState(player_id="player1") - rules = RulesConfig() + from app.core.config import EnergyConfig + + rules = RulesConfig(energy=EnergyConfig(attachments_per_turn=3)) + + assert player.can_attach_energy(rules) + + player.energy_attachments_this_turn = 1 + assert player.can_attach_energy(rules) + + player.energy_attachments_this_turn = 2 + assert player.can_attach_energy(rules) + + player.energy_attachments_this_turn = 3 + assert not player.can_attach_energy(rules) + + def test_can_play_supporter_default_rules(self) -> None: + """ + Verify can_play_supporter() respects turn counter with default rules. + + Default rules allow 1 Supporter per turn. + """ + player = PlayerState(player_id="player1") + rules = RulesConfig() # Default: supporters_per_turn = 1 assert player.can_play_supporter(rules) - player.supporter_played_this_turn = True + player.supporters_played_this_turn = 1 assert not player.can_play_supporter(rules) - def test_can_retreat(self) -> None: + def test_can_play_supporter_multiple_per_turn(self) -> None: """ - Verify can_retreat() respects turn flag and rules. + Verify can_play_supporter() respects custom supporters_per_turn rule. + + Tests configurable Supporter limits for custom game modes. """ player = PlayerState(player_id="player1") - rules = RulesConfig() + from app.core.config import TrainerConfig + + rules = RulesConfig(trainer=TrainerConfig(supporters_per_turn=2)) + + assert player.can_play_supporter(rules) + + player.supporters_played_this_turn = 1 + assert player.can_play_supporter(rules) + + player.supporters_played_this_turn = 2 + assert not player.can_play_supporter(rules) + + def test_can_play_stadium(self) -> None: + """ + Verify can_play_stadium() respects turn counter and rules. + + Default rules allow 1 Stadium per turn. + """ + player = PlayerState(player_id="player1") + rules = RulesConfig() # Default: stadiums_per_turn = 1 + + assert player.can_play_stadium(rules) + + player.stadiums_played_this_turn = 1 + assert not player.can_play_stadium(rules) + + def test_can_play_item_unlimited(self) -> None: + """ + Verify can_play_item() allows unlimited Items when items_per_turn is None. + + In standard Pokemon TCG rules, Items have no per-turn limit. + """ + player = PlayerState(player_id="player1") + rules = RulesConfig() # Default: items_per_turn = None (unlimited) + + assert player.can_play_item(rules) + + player.items_played_this_turn = 10 + assert player.can_play_item(rules) + + player.items_played_this_turn = 100 + assert player.can_play_item(rules) + + def test_can_play_item_limited(self) -> None: + """ + Verify can_play_item() respects limit when items_per_turn is set. + + Some custom game modes may limit Items per turn for balance. + """ + player = PlayerState(player_id="player1") + from app.core.config import TrainerConfig + + rules = RulesConfig(trainer=TrainerConfig(items_per_turn=2)) + + assert player.can_play_item(rules) + + player.items_played_this_turn = 1 + assert player.can_play_item(rules) + + player.items_played_this_turn = 2 + assert not player.can_play_item(rules) + + def test_can_retreat_default_rules(self) -> None: + """ + Verify can_retreat() respects turn counter with default rules. + + Default rules allow 1 retreat per turn. + """ + player = PlayerState(player_id="player1") + rules = RulesConfig() # Default: retreats_per_turn = 1 assert player.can_retreat(rules) - player.retreated_this_turn = True + player.retreats_this_turn = 1 + assert not player.can_retreat(rules) + + def test_can_retreat_disabled(self) -> None: + """ + Verify can_retreat() returns False when retreats_per_turn is 0. + + Some game modes may disable retreating entirely. + """ + player = PlayerState(player_id="player1") + from app.core.config import RetreatConfig + + rules = RulesConfig(retreat=RetreatConfig(retreats_per_turn=0)) + + assert not player.can_retreat(rules) + + def test_can_retreat_multiple_per_turn(self) -> None: + """ + Verify can_retreat() respects custom retreats_per_turn rule. + + Some game modes may allow multiple retreats per turn. + """ + player = PlayerState(player_id="player1") + from app.core.config import RetreatConfig + + rules = RulesConfig(retreat=RetreatConfig(retreats_per_turn=2)) + + assert player.can_retreat(rules) + + player.retreats_this_turn = 1 + assert player.can_retreat(rules) + + player.retreats_this_turn = 2 assert not player.can_retreat(rules) def test_bench_space_available(self) -> None: @@ -540,28 +680,33 @@ class TestPlayerStateTurnActions: assert not player.can_bench_pokemon(rules) - def test_reset_turn_flags(self) -> None: + def test_reset_turn_state(self) -> None: """ - Verify reset_turn_flags() clears all per-turn flags. + Verify reset_turn_state() resets all per-turn counters to 0. + + This is called at the start of each turn to allow the player to + take all their per-turn actions again. """ 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 + player.energy_attachments_this_turn = 3 + player.supporters_played_this_turn = 2 + player.stadiums_played_this_turn = 1 + player.items_played_this_turn = 5 + player.retreats_this_turn = 2 - # Add a pokemon with ability used + # Add a pokemon with ability uses this turn pokemon = make_card_instance("pokemon-1") - pokemon.ability_used_this_turn = True + pokemon.ability_uses_this_turn = 3 player.active.add(pokemon) - player.reset_turn_flags() + player.reset_turn_state() - 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 + assert player.energy_attachments_this_turn == 0 + assert player.supporters_played_this_turn == 0 + assert player.stadiums_played_this_turn == 0 + assert player.items_played_this_turn == 0 + assert player.retreats_this_turn == 0 + assert pokemon.ability_uses_this_turn == 0 # ============================================================================ @@ -710,6 +855,12 @@ class TestGameStateTurnManagement: def test_advance_turn(self) -> None: """ Verify advance_turn() switches players and updates state. + + Tests that advancing the turn: + 1. Switches to the next player in turn order + 2. Marks first turn as completed after turn 1 + 3. Sets phase to DRAW + 4. Resets the new player's turn counters """ game = GameState( game_id="game-1", @@ -723,15 +874,17 @@ class TestGameStateTurnManagement: phase=TurnPhase.END, ) - # Set some turn flags on player 2 to verify reset - game.players["player2"].energy_attached_this_turn = True + # Set some turn counters on player 2 to verify reset + game.players["player2"].energy_attachments_this_turn = 1 + game.players["player2"].supporters_played_this_turn = 1 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 + assert game.players["player2"].energy_attachments_this_turn == 0 + assert game.players["player2"].supporters_played_this_turn == 0 def test_advance_turn_wraps_around(self) -> None: """ @@ -886,11 +1039,14 @@ class TestGameStateJsonRoundTrip: def test_player_state_round_trip(self) -> None: """ Verify PlayerState serializes and deserializes correctly. + + Tests that all fields including turn counters survive JSON round-trip. """ player = PlayerState(player_id="player1") player.hand.add(make_card_instance("hand-1")) player.score = 3 - player.energy_attached_this_turn = True + player.energy_attachments_this_turn = 2 + player.supporters_played_this_turn = 1 json_str = player.model_dump_json() restored = PlayerState.model_validate_json(json_str) @@ -898,7 +1054,8 @@ class TestGameStateJsonRoundTrip: assert restored.player_id == "player1" assert len(restored.hand) == 1 assert restored.score == 3 - assert restored.energy_attached_this_turn is True + assert restored.energy_attachments_this_turn == 2 + assert restored.supporters_played_this_turn == 1 def test_game_state_round_trip(self) -> None: """