Add test coverage for validation and confusion, document effect knockout handling

Issue #2 gap: Added 14 CardDefinition validation tests covering all
required field checks (hp, stage, pokemon_type, evolves_from, trainer_type,
energy_type) with both negative and positive test cases.

Issue #7 gap: Added 4 confusion attack engine tests covering heads/tails
outcomes, self-damage, self-KO with opponent scoring, and configurable
damage from RulesConfig.

Issue #13 documentation: Added TODO comments in engine.py and handlers.py
documenting the expected pattern for knockout detection when effect
execution is implemented. Effect handlers set knockout flags; engine
should process knockouts after all effects resolve.

825 tests passing (+17 new tests)
This commit is contained in:
Cal Corum 2026-01-26 14:16:15 -06:00
parent 939ae421aa
commit 5f1eb11344
4 changed files with 557 additions and 0 deletions

View File

@ -425,10 +425,33 @@ def handle_remove_status(ctx: EffectContext) -> EffectResult:
)
# TODO: KNOCKOUT DETECTION FOR EFFECT-BASED DAMAGE
#
# The handlers below (coin_flip_damage, bench_damage) apply damage but do NOT
# currently detect knockouts. This is intentional for now because:
#
# 1. The engine doesn't yet execute attack effects - only base damage is applied
# 2. When effect execution is added, the engine should handle knockout detection
# AFTER all effects resolve, not during each effect
# 3. The deal_damage/attack_damage handlers set knockout flags as informational
# markers that the engine can use to identify which Pokemon to check
#
# When implementing effect execution in engine.py:
# - Execute all effects and collect EffectResults
# - Check details["knockout"] flag in each result
# - Call process_knockout() for each unique knockout_pokemon_id
# - Handle both active and bench knockouts (including multi-knockout scenarios)
#
# See: engine.py _execute_attack() for the TODO on integrating this
@effect_handler("coin_flip_damage")
def handle_coin_flip_damage(ctx: EffectContext) -> EffectResult:
"""Deal damage based on coin flip results.
Note: This handler does NOT check for knockouts. The engine is responsible
for knockout detection after all effects resolve. See module-level TODO.
Params:
damage_per_heads (int): Damage dealt per heads result. Required.
flip_count (int): Number of coins to flip. Default 1.
@ -772,6 +795,9 @@ def handle_shuffle_deck(ctx: EffectContext) -> EffectResult:
def handle_bench_damage(ctx: EffectContext) -> EffectResult:
"""Deal damage to benched Pokemon.
Note: This handler does NOT check for knockouts. The engine is responsible
for knockout detection after all effects resolve. See module-level TODO.
Params:
amount (int): Damage to deal to each target. Required.
target_opponent (bool): Target opponent's bench. Default True.

View File

@ -765,6 +765,18 @@ class GameEngine:
return ActionResult(success=False, message="Opponent has no active Pokemon")
# Calculate and apply damage (simplified)
# TODO: EFFECT EXECUTION - When attack effects are implemented, they should be
# executed here BEFORE knockout detection. Effects like coin_flip_damage and
# bench_damage will deal additional damage. After ALL effects resolve, iterate
# through all damaged Pokemon (defender, benched Pokemon, even attacker from
# recoil) and call process_knockout() for each KO'd Pokemon.
#
# The damage handlers (deal_damage, attack_damage) set details["knockout"]=True
# when damage KOs a target - use this to identify which Pokemon need knockout
# processing without re-checking every Pokemon.
#
# See: app/core/effects/handlers.py for knockout flag pattern
# See: SYSTEM_REVIEW.md Issue #13 for context
base_damage = attack.damage or 0
defender.damage += base_damage

View File

@ -2393,6 +2393,257 @@ class TestDeckOutAndEdgeCases:
assert not result.success
# =============================================================================
# Confusion Status - Attack Tests
# =============================================================================
class TestConfusionAttack:
"""Tests for confusion status during attack execution.
When a confused Pokemon attacks, it must flip a coin:
- Heads: attack proceeds normally
- Tails: attack fails and Pokemon damages itself
These tests verify the engine correctly handles both outcomes,
including edge cases like self-KO from confusion damage.
"""
@pytest.mark.asyncio
async def test_confused_attack_heads_proceeds_normally(
self,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that a confused Pokemon's attack proceeds normally on heads.
When the confusion coin flip is heads, the attack should execute
as if the Pokemon was not confused, dealing normal damage to defender.
"""
from app.core.rng import SeededRandom
# Create engine - we'll replace RNG after game creation
engine = GameEngine()
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Replace RNG with one that will flip heads (seed=1 produces heads)
engine.rng = SeededRandom(seed=1)
p1 = game.players["player1"]
p2 = game.players["player2"]
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
attacker.attach_energy(energy)
p1.active.add(attacker)
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2.active.add(defender)
attacker.add_status(StatusCondition.CONFUSED)
game.current_player_id = "player1" # Set player1's turn
game.phase = TurnPhase.ATTACK
game.turn_number = 2
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
assert "heads" in result.message.lower()
# Defender should have taken damage
assert defender.damage > 0
# Attacker should not have self-damage
assert attacker.damage == 0
@pytest.mark.asyncio
async def test_confused_attack_tails_fails_with_self_damage(
self,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that a confused Pokemon damages itself on tails.
When the confusion coin flip is tails, the attack should fail
and the Pokemon should deal self-damage (default 30).
"""
from app.core.rng import SeededRandom
# Create engine - we'll replace RNG after game creation
engine = GameEngine()
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Replace RNG with one that will flip tails (seed=0 produces tails)
engine.rng = SeededRandom(seed=0)
p1 = game.players["player1"]
p2 = game.players["player2"]
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
attacker.attach_energy(energy)
p1.active.add(attacker)
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2.active.add(defender)
attacker.add_status(StatusCondition.CONFUSED)
game.current_player_id = "player1" # Set player1's turn
game.phase = TurnPhase.ATTACK
game.turn_number = 2
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
# Action still succeeds (coin was flipped), but attack failed
assert result.success
assert "tails" in result.message.lower()
assert "self-damage" in result.message.lower() or "30" in result.message
# Attacker should have self-damage (default 30)
assert attacker.damage == 30
# Defender should NOT have taken damage
assert defender.damage == 0
@pytest.mark.asyncio
async def test_confused_attack_tails_self_ko(
self,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that confusion self-damage can knock out the attacker.
If the attacker has low HP remaining, the 30 self-damage from
confusion can knock it out. The opponent should receive points/prizes.
"""
from app.core.rng import SeededRandom
# Create engine - we'll replace RNG after game creation
engine = GameEngine()
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Replace RNG with one that will flip tails (seed=0 produces tails)
engine.rng = SeededRandom(seed=0)
p1 = game.players["player1"]
p2 = game.players["player2"]
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
attacker.attach_energy(energy)
# Give attacker damage so self-damage will KO it (60 HP Pokemon)
attacker.damage = 40 # 40 + 30 self-damage = 70 > 60 HP
p1.active.add(attacker)
# Add bench Pokemon so game doesn't end immediately
bench = CardInstance(instance_id="p1-bench", definition_id=basic_pokemon_def.id)
p1.bench.add(bench)
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2.active.add(defender)
attacker.add_status(StatusCondition.CONFUSED)
game.current_player_id = "player1" # Set player1's turn
game.phase = TurnPhase.ATTACK
game.turn_number = 2
initial_p2_score = p2.score
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
# Attacker should be knocked out (in discard, not in active)
assert p1.active.get(attacker.instance_id) is None
assert p1.discard.get(attacker.instance_id) is not None
# Opponent should have scored
assert p2.score > initial_p2_score
@pytest.mark.asyncio
async def test_confused_attack_uses_config_self_damage(
self,
player1_deck: list[CardInstance],
player2_deck: list[CardInstance],
card_registry: dict[str, CardDefinition],
basic_pokemon_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that confusion self-damage is configurable via RulesConfig.
The self-damage amount should come from rules.status.confusion_self_damage.
"""
from app.core.config import RulesConfig, StatusConfig
from app.core.rng import SeededRandom
# Create engine - we'll replace RNG after game creation
engine = GameEngine()
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": player1_deck, "player2": player2_deck},
card_registry=card_registry,
)
game = result.game
# Replace RNG with one that will flip tails (seed=0 produces tails)
engine.rng = SeededRandom(seed=0)
# Custom rules with different confusion damage
game.rules = RulesConfig(status=StatusConfig(confusion_self_damage=50))
p1 = game.players["player1"]
p2 = game.players["player2"]
attacker = CardInstance(instance_id="p1-active", definition_id=basic_pokemon_def.id)
energy = CardInstance(instance_id="attacker-energy", definition_id=energy_def.id)
attacker.attach_energy(energy)
p1.active.add(attacker)
defender = CardInstance(instance_id="p2-active", definition_id=basic_pokemon_def.id)
p2.active.add(defender)
attacker.add_status(StatusCondition.CONFUSED)
game.current_player_id = "player1" # Set player1's turn
game.phase = TurnPhase.ATTACK
game.turn_number = 2
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
# Attacker should have custom self-damage amount
assert attacker.damage == 50
# =============================================================================
# Attach Energy - Energy Zone Tests
# =============================================================================

View File

@ -677,6 +677,274 @@ class TestCardDefinitionEnergy:
assert rainbow.effect_id == "rainbow_damage"
class TestCardDefinitionValidation:
"""Tests for CardDefinition field validation.
These tests verify that the model_validator correctly enforces required
fields based on card type. Each test uses pytest.raises to confirm that
invalid card definitions are rejected at construction time.
This explicit negative testing ensures validation rules are documented
and protected against regression.
"""
import pytest
def test_pokemon_without_hp_raises(self) -> None:
"""
Verify Pokemon cards must have hp field set.
Pokemon without HP would break damage calculations and knockout detection.
"""
import pytest
with pytest.raises(ValueError, match="hp"):
CardDefinition(
id="invalid-pokemon",
name="Invalid",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
pokemon_type=EnergyType.FIRE,
# hp is missing
)
def test_pokemon_with_zero_hp_raises(self) -> None:
"""
Verify Pokemon cards must have positive hp.
Zero HP Pokemon would be instantly knocked out, which is invalid.
"""
import pytest
with pytest.raises(ValueError, match="positive"):
CardDefinition(
id="invalid-pokemon",
name="Invalid",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
pokemon_type=EnergyType.FIRE,
hp=0,
)
def test_pokemon_with_negative_hp_raises(self) -> None:
"""
Verify Pokemon cards reject negative hp values.
Negative HP is nonsensical and would break game logic.
"""
import pytest
with pytest.raises(ValueError, match="positive"):
CardDefinition(
id="invalid-pokemon",
name="Invalid",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
pokemon_type=EnergyType.FIRE,
hp=-10,
)
def test_pokemon_without_stage_raises(self) -> None:
"""
Verify Pokemon cards must have stage field set.
Stage is required for evolution chain validation and gameplay rules.
"""
import pytest
with pytest.raises(ValueError, match="stage"):
CardDefinition(
id="invalid-pokemon",
name="Invalid",
card_type=CardType.POKEMON,
hp=60,
pokemon_type=EnergyType.FIRE,
# stage is missing
)
def test_pokemon_without_type_raises(self) -> None:
"""
Verify Pokemon cards must have pokemon_type field set.
Type is required for weakness/resistance calculations.
"""
import pytest
with pytest.raises(ValueError, match="pokemon_type"):
CardDefinition(
id="invalid-pokemon",
name="Invalid",
card_type=CardType.POKEMON,
hp=60,
stage=PokemonStage.BASIC,
# pokemon_type is missing
)
def test_stage_1_without_evolves_from_raises(self) -> None:
"""
Verify Stage 1 Pokemon must specify what they evolve from.
Stage 1 Pokemon must have evolves_from to validate evolution chains.
"""
import pytest
with pytest.raises(ValueError, match="evolves_from"):
CardDefinition(
id="invalid-stage1",
name="Invalid Stage 1",
card_type=CardType.POKEMON,
hp=90,
stage=PokemonStage.STAGE_1,
pokemon_type=EnergyType.FIRE,
# evolves_from is missing
)
def test_stage_2_without_evolves_from_raises(self) -> None:
"""
Verify Stage 2 Pokemon must specify what they evolve from.
Stage 2 Pokemon must have evolves_from to validate evolution chains.
"""
import pytest
with pytest.raises(ValueError, match="evolves_from"):
CardDefinition(
id="invalid-stage2",
name="Invalid Stage 2",
card_type=CardType.POKEMON,
hp=150,
stage=PokemonStage.STAGE_2,
pokemon_type=EnergyType.FIRE,
# evolves_from is missing
)
def test_vmax_without_evolves_from_raises(self) -> None:
"""
Verify VMAX Pokemon must specify what they evolve from.
VMAX are evolution variants that require a base V Pokemon.
"""
import pytest
with pytest.raises(ValueError, match="evolves_from"):
CardDefinition(
id="invalid-vmax",
name="Invalid VMAX",
card_type=CardType.POKEMON,
hp=320,
stage=PokemonStage.BASIC, # VMAX can be BASIC stage but still evolves
variant=PokemonVariant.VMAX,
pokemon_type=EnergyType.FIRE,
# evolves_from is missing
)
def test_vstar_without_evolves_from_raises(self) -> None:
"""
Verify VSTAR Pokemon must specify what they evolve from.
VSTAR are evolution variants that require a base V Pokemon.
"""
import pytest
with pytest.raises(ValueError, match="evolves_from"):
CardDefinition(
id="invalid-vstar",
name="Invalid VSTAR",
card_type=CardType.POKEMON,
hp=280,
stage=PokemonStage.BASIC,
variant=PokemonVariant.VSTAR,
pokemon_type=EnergyType.FIRE,
# evolves_from is missing
)
def test_trainer_without_trainer_type_raises(self) -> None:
"""
Verify Trainer cards must have trainer_type field set.
Trainer type (Item, Supporter, Stadium) determines gameplay rules.
"""
import pytest
with pytest.raises(ValueError, match="trainer_type"):
CardDefinition(
id="invalid-trainer",
name="Invalid Trainer",
card_type=CardType.TRAINER,
# trainer_type is missing
)
def test_energy_without_energy_type_raises(self) -> None:
"""
Verify Energy cards must have energy_type field set.
Energy type determines what attack costs the energy can satisfy.
"""
import pytest
with pytest.raises(ValueError, match="energy_type"):
CardDefinition(
id="invalid-energy",
name="Invalid Energy",
card_type=CardType.ENERGY,
# energy_type is missing
)
def test_valid_basic_pokemon_passes(self) -> None:
"""
Verify valid Basic Pokemon passes validation (sanity check).
Ensures validation doesn't reject valid cards.
"""
pokemon = CardDefinition(
id="valid-pokemon",
name="Valid Pokemon",
card_type=CardType.POKEMON,
hp=60,
stage=PokemonStage.BASIC,
pokemon_type=EnergyType.FIRE,
)
assert pokemon.hp == 60
assert pokemon.stage == PokemonStage.BASIC
assert pokemon.pokemon_type == EnergyType.FIRE
def test_valid_stage_1_with_evolves_from_passes(self) -> None:
"""
Verify valid Stage 1 Pokemon with evolves_from passes validation.
"""
pokemon = CardDefinition(
id="valid-stage1",
name="Valid Stage 1",
card_type=CardType.POKEMON,
hp=90,
stage=PokemonStage.STAGE_1,
pokemon_type=EnergyType.FIRE,
evolves_from="charmander",
)
assert pokemon.stage == PokemonStage.STAGE_1
assert pokemon.evolves_from == "charmander"
def test_valid_vmax_with_evolves_from_passes(self) -> None:
"""
Verify valid VMAX Pokemon with evolves_from passes validation.
"""
pokemon = CardDefinition(
id="valid-vmax",
name="Valid VMAX",
card_type=CardType.POKEMON,
hp=320,
stage=PokemonStage.BASIC,
variant=PokemonVariant.VMAX,
pokemon_type=EnergyType.FIRE,
evolves_from="charizard-v",
)
assert pokemon.variant == PokemonVariant.VMAX
assert pokemon.evolves_from == "charizard-v"
class TestCardDefinitionHelperMethodsOnNonPokemon:
"""Tests for Pokemon-specific helper methods when called on non-Pokemon cards.