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.
This commit is contained in:
Cal Corum 2026-01-24 23:16:37 -06:00
parent 725c8ccc5c
commit 325f1e8af5
4 changed files with 387 additions and 79 deletions

View File

@ -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.

View File

@ -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

View File

@ -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:

View File

@ -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:
"""