Add stat modifiers and attack cost overrides to CardInstance

Support card effects that modify Pokemon stats:
- hp_modifier: Additive HP change (e.g., +20 from Giant Cape tool)
- retreat_cost_modifier: Additive retreat cost change (e.g., -99 for Float Stone)
- damage_modifier: Additive damage change for attacks
- attack_cost_overrides: dict[int, list[EnergyType]] for per-attack cost changes

Added helper methods:
- effective_hp(base_hp) - returns max(1, base_hp + hp_modifier)
- effective_retreat_cost(base_cost) - returns max(0, base_cost + modifier)
- effective_attack_cost(attack_index, base_cost) - returns override or base

Updated is_knocked_out() and remaining_hp() to use effective HP.

Also updated PROJECT_PLAN.json:
- Marked HIGH-003, TEST-006, HIGH-004 as completed (Week 2 complete)
- Updated test counts and notes to reflect stat modifier coverage

289 tests passing.
This commit is contained in:
Cal Corum 2026-01-24 23:52:20 -06:00
parent 325f1e8af5
commit 092f493cc8
3 changed files with 358 additions and 24 deletions

View File

@ -8,7 +8,7 @@
"description": "Core game engine scaffolding for a highly configurable Pokemon TCG-inspired card game. The engine must support campaign mode with fixed rules and free play mode with user-configurable rules.", "description": "Core game engine scaffolding for a highly configurable Pokemon TCG-inspired card game. The engine must support campaign mode with fixed rules and free play mode with user-configurable rules.",
"totalEstimatedHours": 48, "totalEstimatedHours": 48,
"totalTasks": 32, "totalTasks": 32,
"completedTasks": 10 "completedTasks": 14
}, },
"categories": { "categories": {
"critical": "Foundation components that block all other work", "critical": "Foundation components that block all other work",
@ -170,7 +170,7 @@
], ],
"suggestedFix": "CardDefinition: immutable template with id, name, card_type, stage, variant, hp, attacks, abilities, weakness, resistance, retreat_cost. CardInstance: mutable state with instance_id, definition_id, damage, attached_energy, status_conditions, turn_played.", "suggestedFix": "CardDefinition: immutable template with id, name, card_type, stage, variant, hp, attacks, abilities, weakness, resistance, retreat_cost. CardInstance: mutable state with instance_id, definition_id, damage, attached_energy, status_conditions, turn_played.",
"estimatedHours": 2, "estimatedHours": 2,
"notes": "CardDefinition has separate stage (evolution) and variant (knockout points) fields. Includes helper methods: is_basic_pokemon(), is_evolution(), requires_evolution_from_variant(), knockout_points(). CardInstance has status condition management with override rules.", "notes": "CardDefinition has separate stage (evolution) and variant (knockout points) fields. Includes helper methods: is_basic_pokemon(), is_evolution(), requires_evolution_from_variant(), knockout_points(). CardInstance has status condition management with override rules. Added stat modifiers: hp_modifier, retreat_cost_modifier, damage_modifier, attack_cost_overrides for effect support. Ability.uses_per_turn (int|None) replaces once_per_turn boolean for configurable limits.",
"completedDate": "2026-01-25" "completedDate": "2026-01-25"
}, },
{ {
@ -187,7 +187,7 @@
], ],
"suggestedFix": "Test: Pokemon card creation with attacks, Trainer card creation, Energy card creation, CardInstance damage tracking, energy attachment, status conditions", "suggestedFix": "Test: Pokemon card creation with attacks, Trainer card creation, Energy card creation, CardInstance damage tracking, energy attachment, status conditions",
"estimatedHours": 1.5, "estimatedHours": 1.5,
"notes": "54 tests covering all card types, stage/variant combinations (Basic EX, Stage 2 GX, etc.), status condition override rules, evolution timing, energy attachment/detachment", "notes": "77 tests covering all card types, stage/variant combinations (Basic EX, Stage 2 GX, etc.), status condition override rules, evolution timing, energy attachment/detachment, stat modifiers (HP, retreat cost, damage), attack cost overrides, ability usage limits (once/multiple/unlimited per turn).",
"completedDate": "2026-01-25" "completedDate": "2026-01-25"
}, },
{ {
@ -230,15 +230,16 @@
"description": "Define the complete game state model hierarchy: Zone (card collection with operations), PlayerState (all player zones and turn state), GameState (full game including card_registry, rules, players, turn tracking)", "description": "Define the complete game state model hierarchy: Zone (card collection with operations), PlayerState (all player zones and turn state), GameState (full game including card_registry, rules, players, turn tracking)",
"category": "high", "category": "high",
"priority": 12, "priority": 12,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": ["CRIT-003", "HIGH-001"], "dependencies": ["CRIT-003", "HIGH-001"],
"files": [ "files": [
{"path": "app/core/models/game_state.py", "issue": "File does not exist"} {"path": "app/core/models/game_state.py", "status": "created"}
], ],
"suggestedFix": "Zone: list of CardInstance with add/remove/shuffle/draw methods. PlayerState: deck, hand, active, bench, prizes, discard zones plus turn state flags. GameState: game_id, rules, card_registry, players dict, turn tracking, winner.", "suggestedFix": "Zone: list of CardInstance with add/remove/shuffle/draw methods. PlayerState: deck, hand, active, bench, prizes, discard zones plus turn state flags. GameState: game_id, rules, card_registry, players dict, turn tracking, winner.",
"estimatedHours": 3, "estimatedHours": 3,
"notes": "GameState.card_registry holds all CardDefinitions used in this game. Zone operations need RandomProvider for shuffle." "notes": "GameState.card_registry holds all CardDefinitions used in this game. Zone operations need RandomProvider for shuffle. PlayerState uses integer counters (not booleans) for per-turn actions to support configurable RulesConfig limits.",
"completedDate": "2026-01-25"
}, },
{ {
"id": "TEST-006", "id": "TEST-006",
@ -246,15 +247,16 @@
"description": "Test Zone operations, PlayerState initialization and turn resets, GameState properties and player access", "description": "Test Zone operations, PlayerState initialization and turn resets, GameState properties and player access",
"category": "high", "category": "high",
"priority": 13, "priority": 13,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": ["HIGH-003"], "dependencies": ["HIGH-003"],
"files": [ "files": [
{"path": "tests/core/test_models/test_game_state.py", "issue": "File does not exist"} {"path": "tests/core/test_models/test_game_state.py", "status": "created"}
], ],
"suggestedFix": "Test: Zone add/remove/shuffle/draw, PlayerState turn state resets, GameState current_player property, GameState is_first_turn logic", "suggestedFix": "Test: Zone add/remove/shuffle/draw, PlayerState turn state resets, GameState current_player property, GameState is_first_turn logic",
"estimatedHours": 2, "estimatedHours": 2,
"notes": "Use SeededRandom for deterministic shuffle tests" "notes": "62 tests covering Zone operations, PlayerState counters with configurable rules (energy, supporters, items, stadiums, retreats), and GameState turn management. Uses SeededRandom for deterministic shuffle tests.",
"completedDate": "2026-01-25"
}, },
{ {
"id": "HIGH-004", "id": "HIGH-004",
@ -262,15 +264,16 @@
"description": "Create shared pytest fixtures for sample cards, game states, and seeded RNG instances", "description": "Create shared pytest fixtures for sample cards, game states, and seeded RNG instances",
"category": "high", "category": "high",
"priority": 14, "priority": 14,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": ["HIGH-001", "HIGH-003", "CRIT-004"], "dependencies": ["HIGH-001", "HIGH-003", "CRIT-004"],
"files": [ "files": [
{"path": "tests/core/conftest.py", "issue": "File does not exist"} {"path": "tests/core/conftest.py", "status": "created"}
], ],
"suggestedFix": "Create fixtures: sample_pokemon_card, sample_trainer_card, sample_energy_card, sample_deck, empty_game_state, mid_game_state, seeded_rng", "suggestedFix": "Create fixtures: sample_pokemon_card, sample_trainer_card, sample_energy_card, sample_deck, empty_game_state, mid_game_state, seeded_rng",
"estimatedHours": 1.5, "estimatedHours": 1.5,
"notes": "Fixtures should be composable and reusable across all test modules" "notes": "Fixtures include sample cards (Pikachu, Raichu, Charmander), factory functions for CardInstance/GameState creation, and SeededRandom fixtures for deterministic tests.",
"completedDate": "2026-01-25"
}, },
{ {
"id": "MED-001", "id": "MED-001",
@ -608,8 +611,9 @@
"tasks": ["HIGH-001", "TEST-004", "HIGH-002", "TEST-005", "HIGH-003", "TEST-006", "HIGH-004"], "tasks": ["HIGH-001", "TEST-004", "HIGH-002", "TEST-005", "HIGH-003", "TEST-006", "HIGH-004"],
"estimatedHours": 12, "estimatedHours": 12,
"goals": ["All data models complete", "Test fixtures established"], "goals": ["All data models complete", "Test fixtures established"],
"status": "IN_PROGRESS", "status": "COMPLETED",
"progress": "Card and Action models complete (4/7 tasks). GameState, fixtures remaining." "completedDate": "2026-01-25",
"progress": "All 7 tasks complete. CardInstance includes stat modifiers (hp_modifier, retreat_cost_modifier, damage_modifier, attack_cost_overrides) for effect support. PlayerState uses integer counters for configurable per-turn action limits. 289 tests passing."
}, },
"week3": { "week3": {
"theme": "Effects System", "theme": "Effects System",

View File

@ -232,6 +232,11 @@ class CardInstance(BaseModel):
The definition_id links back to the CardDefinition in the game's card registry. The definition_id links back to the CardDefinition in the game's card registry.
Stat Modifiers:
Card effects can modify base stats using the modifier fields. These are
additive: effective_stat = base_stat + modifier. For example, a Tool card
that grants +20 HP would set hp_modifier=20.
Attributes: Attributes:
instance_id: Unique identifier for this specific instance (UUID). instance_id: Unique identifier for this specific instance (UUID).
definition_id: Reference to the CardDefinition.id. definition_id: Reference to the CardDefinition.id.
@ -241,6 +246,18 @@ class CardInstance(BaseModel):
status_conditions: Active status conditions on this Pokemon. status_conditions: Active status conditions on this Pokemon.
ability_uses_this_turn: Number of times abilities have been 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. Compared against Ability.uses_per_turn to determine if more uses allowed.
hp_modifier: Additive modifier to the Pokemon's base HP. Can be positive
(e.g., from Tool cards) or negative (e.g., from effects).
retreat_cost_modifier: Additive modifier to retreat cost. Negative values
reduce the cost (e.g., Float Stone sets this to -99 to effectively
make retreat free). Effective retreat cost is clamped to minimum 0.
damage_modifier: Additive modifier to damage dealt by this Pokemon's attacks.
Positive values increase damage, negative reduce it.
attack_cost_overrides: Override the energy cost for specific attacks. Maps
attack index (0-based) to a new list of EnergyType requirements. If an
attack index is not in this dict, the base cost from CardDefinition is used.
Example: {0: [EnergyType.COLORLESS, EnergyType.COLORLESS]} makes the first
attack cost 2 colorless instead of its normal cost.
evolved_from_instance_id: The CardInstance this evolved from. evolved_from_instance_id: The CardInstance this evolved from.
turn_played: The turn number when this card was played/evolved. turn_played: The turn number when this card was played/evolved.
Used for evolution timing rules. Used for evolution timing rules.
@ -258,32 +275,83 @@ class CardInstance(BaseModel):
status_conditions: list[StatusCondition] = Field(default_factory=list) status_conditions: list[StatusCondition] = Field(default_factory=list)
ability_uses_this_turn: int = 0 ability_uses_this_turn: int = 0
# Stat modifiers (applied by card effects, tools, abilities, etc.)
hp_modifier: int = 0
retreat_cost_modifier: int = 0
damage_modifier: int = 0
attack_cost_overrides: dict[int, list[EnergyType]] = Field(default_factory=dict)
# Evolution tracking # Evolution tracking
evolved_from_instance_id: str | None = None evolved_from_instance_id: str | None = None
turn_played: int | None = None turn_played: int | None = None
turn_evolved: int | None = None turn_evolved: int | None = None
def is_knocked_out(self, hp: int) -> bool: def effective_hp(self, base_hp: int) -> int:
"""Calculate effective max HP including modifiers.
Args:
base_hp: The Pokemon's base HP (from CardDefinition.hp).
Returns:
Effective max HP (minimum 1 to prevent instant KO from negative modifiers).
"""
return max(1, base_hp + self.hp_modifier)
def effective_retreat_cost(self, base_cost: int) -> int:
"""Calculate effective retreat cost including modifiers.
Args:
base_cost: The Pokemon's base retreat cost (from CardDefinition.retreat_cost).
Returns:
Effective retreat cost (minimum 0, can't be negative).
"""
return max(0, base_cost + self.retreat_cost_modifier)
def effective_attack_cost(
self, attack_index: int, base_cost: list[EnergyType]
) -> list[EnergyType]:
"""Get the effective energy cost for an attack, including any overrides.
Args:
attack_index: The index of the attack in CardDefinition.attacks (0-based).
base_cost: The attack's base energy cost (from Attack.cost).
Returns:
The effective energy cost - either the override if set, or the base cost.
Example:
# An opponent's effect makes your first attack cost 2 more colorless
instance.attack_cost_overrides[0] = base_cost + [EnergyType.COLORLESS] * 2
# A control effect completely changes the cost
instance.attack_cost_overrides[1] = [EnergyType.PSYCHIC, EnergyType.PSYCHIC]
"""
if attack_index in self.attack_cost_overrides:
return self.attack_cost_overrides[attack_index]
return base_cost
def is_knocked_out(self, base_hp: int) -> bool:
"""Check if this Pokemon is knocked out. """Check if this Pokemon is knocked out.
Args: Args:
hp: The Pokemon's max HP (from CardDefinition). base_hp: The Pokemon's base HP (from CardDefinition.hp).
Returns: Returns:
True if damage >= hp. True if damage >= effective HP.
""" """
return self.damage >= hp return self.damage >= self.effective_hp(base_hp)
def remaining_hp(self, hp: int) -> int: def remaining_hp(self, base_hp: int) -> int:
"""Calculate remaining HP. """Calculate remaining HP.
Args: Args:
hp: The Pokemon's max HP (from CardDefinition). base_hp: The Pokemon's base HP (from CardDefinition.hp).
Returns: Returns:
Remaining HP (minimum 0). Remaining HP (minimum 0).
""" """
return max(0, hp - self.damage) return max(0, self.effective_hp(base_hp) - self.damage)
def has_status(self, status: StatusCondition) -> bool: def has_status(self, status: StatusCondition) -> bool:
"""Check if this Pokemon has a specific status condition.""" """Check if this Pokemon has a specific status condition."""

View File

@ -804,6 +804,268 @@ class TestCardInstance:
assert instance.remaining_hp(hp) == 0 # Minimum 0 assert instance.remaining_hp(hp) == 0 # Minimum 0
class TestCardInstanceStatModifiers:
"""Tests for stat modifier functionality on CardInstance."""
def test_effective_hp_no_modifier(self) -> None:
"""
Verify effective_hp returns base HP when no modifier is set.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
base_hp = 60
assert instance.effective_hp(base_hp) == 60
def test_effective_hp_positive_modifier(self) -> None:
"""
Verify effective_hp adds positive modifiers (e.g., from Tool cards).
Example: A Tool card like "Giant Cape" grants +20 HP.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.hp_modifier = 20
base_hp = 60
assert instance.effective_hp(base_hp) == 80
def test_effective_hp_negative_modifier(self) -> None:
"""
Verify effective_hp subtracts negative modifiers.
Some effects may reduce a Pokemon's max HP.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.hp_modifier = -20
base_hp = 60
assert instance.effective_hp(base_hp) == 40
def test_effective_hp_minimum_is_one(self) -> None:
"""
Verify effective_hp never goes below 1.
This prevents instant KO from extreme negative modifiers.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.hp_modifier = -100 # More than base HP
base_hp = 60
assert instance.effective_hp(base_hp) == 1
def test_is_knocked_out_with_hp_modifier(self) -> None:
"""
Verify is_knocked_out uses effective HP including modifiers.
A Pokemon with +20 HP modifier should survive 60 damage
when base HP is 60.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.hp_modifier = 20
base_hp = 60
instance.damage = 60
assert instance.is_knocked_out(base_hp) is False # Effective HP is 80
instance.damage = 80
assert instance.is_knocked_out(base_hp) is True
def test_remaining_hp_with_hp_modifier(self) -> None:
"""
Verify remaining_hp uses effective HP including modifiers.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.hp_modifier = 20
base_hp = 60
assert instance.remaining_hp(base_hp) == 80 # Effective HP
instance.damage = 30
assert instance.remaining_hp(base_hp) == 50
def test_effective_retreat_cost_no_modifier(self) -> None:
"""
Verify effective_retreat_cost returns base cost when no modifier is set.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
base_cost = 2
assert instance.effective_retreat_cost(base_cost) == 2
def test_effective_retreat_cost_negative_modifier(self) -> None:
"""
Verify effective_retreat_cost with negative modifier (reduces cost).
Example: An ability or Tool that reduces retreat cost by 1.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.retreat_cost_modifier = -1
base_cost = 2
assert instance.effective_retreat_cost(base_cost) == 1
def test_effective_retreat_cost_free_retreat(self) -> None:
"""
Verify retreat can be made free with large negative modifier.
Example: Float Stone sets retreat_cost_modifier to a large negative
value to effectively make retreat free regardless of base cost.
"""
instance = CardInstance(instance_id="uuid", definition_id="snorlax")
instance.retreat_cost_modifier = -99 # Effectively free
base_cost = 4
assert instance.effective_retreat_cost(base_cost) == 0
def test_effective_retreat_cost_minimum_is_zero(self) -> None:
"""
Verify effective_retreat_cost never goes below 0.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.retreat_cost_modifier = -10
base_cost = 2
assert instance.effective_retreat_cost(base_cost) == 0
def test_effective_retreat_cost_positive_modifier(self) -> None:
"""
Verify effective_retreat_cost with positive modifier (increases cost).
Some effects may increase retreat cost.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
instance.retreat_cost_modifier = 2
base_cost = 1
assert instance.effective_retreat_cost(base_cost) == 3
def test_damage_modifier_default(self) -> None:
"""
Verify damage_modifier defaults to 0.
The damage_modifier field exists for attack damage calculations
in the game engine.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
assert instance.damage_modifier == 0
def test_stat_modifiers_in_json_round_trip(self) -> None:
"""
Verify stat modifiers survive JSON serialization.
"""
instance = CardInstance(
instance_id="uuid",
definition_id="pikachu",
hp_modifier=20,
retreat_cost_modifier=-1,
damage_modifier=10,
)
json_str = instance.model_dump_json()
restored = CardInstance.model_validate_json(json_str)
assert restored.hp_modifier == 20
assert restored.retreat_cost_modifier == -1
assert restored.damage_modifier == 10
def test_effective_attack_cost_no_override(self) -> None:
"""
Verify effective_attack_cost returns base cost when no override is set.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
base_cost = [EnergyType.LIGHTNING, EnergyType.COLORLESS]
result = instance.effective_attack_cost(0, base_cost)
assert result == base_cost
def test_effective_attack_cost_with_override(self) -> None:
"""
Verify effective_attack_cost returns override when set.
Example: A control card effect completely changes the attack cost.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
base_cost = [EnergyType.LIGHTNING]
override_cost = [EnergyType.PSYCHIC, EnergyType.PSYCHIC, EnergyType.COLORLESS]
instance.attack_cost_overrides[0] = override_cost
result = instance.effective_attack_cost(0, base_cost)
assert result == override_cost
assert result != base_cost
def test_effective_attack_cost_different_attacks(self) -> None:
"""
Verify attack cost overrides are per-attack (by index).
One attack can be overridden while another uses base cost.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
attack_0_base = [EnergyType.LIGHTNING]
attack_1_base = [EnergyType.LIGHTNING, EnergyType.LIGHTNING]
# Only override attack 1
instance.attack_cost_overrides[1] = [EnergyType.COLORLESS]
# Attack 0 uses base cost
assert instance.effective_attack_cost(0, attack_0_base) == attack_0_base
# Attack 1 uses override
assert instance.effective_attack_cost(1, attack_1_base) == [EnergyType.COLORLESS]
def test_effective_attack_cost_add_colorless(self) -> None:
"""
Verify adding colorless energy to an attack cost.
Common control effect: "Your opponent's attacks cost 2 more colorless."
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
base_cost = [EnergyType.LIGHTNING]
# Effect adds 2 colorless to the cost
new_cost = base_cost + [EnergyType.COLORLESS, EnergyType.COLORLESS]
instance.attack_cost_overrides[0] = new_cost
result = instance.effective_attack_cost(0, base_cost)
assert result == [EnergyType.LIGHTNING, EnergyType.COLORLESS, EnergyType.COLORLESS]
def test_effective_attack_cost_make_free(self) -> None:
"""
Verify making an attack cost nothing.
Some effects allow attacks for free.
"""
instance = CardInstance(instance_id="uuid", definition_id="pikachu")
base_cost = [EnergyType.LIGHTNING, EnergyType.LIGHTNING, EnergyType.COLORLESS]
instance.attack_cost_overrides[0] = [] # Free!
result = instance.effective_attack_cost(0, base_cost)
assert result == []
def test_attack_cost_overrides_in_json_round_trip(self) -> None:
"""
Verify attack cost overrides survive JSON serialization.
"""
instance = CardInstance(
instance_id="uuid",
definition_id="pikachu",
attack_cost_overrides={
0: [EnergyType.COLORLESS, EnergyType.COLORLESS],
1: [EnergyType.PSYCHIC],
},
)
json_str = instance.model_dump_json()
restored = CardInstance.model_validate_json(json_str)
assert restored.attack_cost_overrides[0] == [EnergyType.COLORLESS, EnergyType.COLORLESS]
assert restored.attack_cost_overrides[1] == [EnergyType.PSYCHIC]
class TestCardInstanceStatusConditions: class TestCardInstanceStatusConditions:
"""Tests for status condition handling on CardInstance.""" """Tests for status condition handling on CardInstance."""