From 5f1eb11344b1991da5fd61ae613f9113a274dced Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 26 Jan 2026 14:16:15 -0600 Subject: [PATCH] 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) --- backend/app/core/effects/handlers.py | 26 ++ backend/app/core/engine.py | 12 + backend/tests/core/test_engine.py | 251 ++++++++++++++++++ backend/tests/core/test_models/test_card.py | 268 ++++++++++++++++++++ 4 files changed, 557 insertions(+) diff --git a/backend/app/core/effects/handlers.py b/backend/app/core/effects/handlers.py index 3ec66ad..74f6b25 100644 --- a/backend/app/core/effects/handlers.py +++ b/backend/app/core/effects/handlers.py @@ -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. diff --git a/backend/app/core/engine.py b/backend/app/core/engine.py index a45c658..d698ec6 100644 --- a/backend/app/core/engine.py +++ b/backend/app/core/engine.py @@ -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 diff --git a/backend/tests/core/test_engine.py b/backend/tests/core/test_engine.py index 7044ab5..27f3204 100644 --- a/backend/tests/core/test_engine.py +++ b/backend/tests/core/test_engine.py @@ -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 # ============================================================================= diff --git a/backend/tests/core/test_models/test_card.py b/backend/tests/core/test_models/test_card.py index 4da8508..2e36758 100644 --- a/backend/tests/core/test_models/test_card.py +++ b/backend/tests/core/test_models/test_card.py @@ -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.