Add knockout detection to damage effect handlers (Issue #13)

Both deal_damage and attack_damage now check if the target is knocked out
after applying damage. If KO'd, EffectResult includes:
- details['knockout'] = True
- details['knockout_pokemon_id'] = target's instance_id
- Message includes 'knocked out!' notification

Knockout check correctly respects HP modifiers via effective_hp().

Added 9 tests covering knockout detection, HP modifier behavior,
weakness-triggered knockouts, and resistance preventing knockouts.
This commit is contained in:
Cal Corum 2026-01-26 11:44:38 -06:00
parent 554178dc6e
commit 1fbd3d1cfa
3 changed files with 240 additions and 7 deletions

View File

@ -208,12 +208,22 @@ Added 2 new tests for stadium ownership behavior.
---
### 13. No Knockout Detection After Damage Effects
### 13. ~~No Knockout Detection After Damage Effects~~ FIXED
**File:** `app/core/effects/handlers.py`
Neither `deal_damage` nor `attack_damage` check if the target is knocked out after applying damage.
~~Neither `deal_damage` nor `attack_damage` check if the target is knocked out after applying damage.~~
**Fix:** Either return a knockout flag in EffectResult or document that knockout checking happens at engine level.
**Resolution:** Both `deal_damage` and `attack_damage` now check if the target is knocked out after applying damage. If knocked out, they set `details["knockout"] = True` and `details["knockout_pokemon_id"]` in the EffectResult. The message also includes "knocked out!" notification.
The knockout check uses `target.is_knocked_out(card_def.hp)` which correctly accounts for HP modifiers via `effective_hp()`.
Added 9 new tests covering:
- Knockout detection for both handlers
- No false positives when target survives
- Knockout with existing damage
- HP modifier respect (both positive and negative)
- Knockout after weakness multiplier
- Survival when resistance prevents knockout
---

View File

@ -74,10 +74,22 @@ def handle_deal_damage(ctx: EffectContext) -> EffectResult:
target.damage += amount
details: dict = {"amount": amount, "target_id": target.instance_id}
# Check for knockout after applying damage
card_def = ctx.game.get_card_definition(target.definition_id)
if card_def and card_def.hp and target.is_knocked_out(card_def.hp):
details["knockout"] = True
details["knockout_pokemon_id"] = target.instance_id
message = f"Dealt {amount} damage"
if details.get("knockout"):
message += f" - {target.definition_id} knocked out!"
return EffectResult.success_result(
f"Dealt {amount} damage",
message,
effect_type=EffectType.DAMAGE,
details={"amount": amount, "target_id": target.instance_id},
details=details,
)
@ -149,12 +161,12 @@ def handle_attack_damage(ctx: EffectContext) -> EffectResult:
# Step 2: Apply weakness and resistance
apply_wr = ctx.get_param("apply_weakness_resistance", True)
target_def = ctx.get_card_definition(target) # Needed for W/R and knockout check
if apply_wr:
source = ctx.get_source_pokemon()
if source:
# Get the attacker's type from its definition
source_def = ctx.get_card_definition(source)
target_def = ctx.get_card_definition(target)
combat_config = ctx.rules.combat
if source_def and target_def and source_def.pokemon_type:
@ -193,8 +205,17 @@ def handle_attack_damage(ctx: EffectContext) -> EffectResult:
target.damage += actual_damage
details["final_damage"] = actual_damage
# Check for knockout after applying damage
if target_def and target_def.hp and target.is_knocked_out(target_def.hp):
details["knockout"] = True
details["knockout_pokemon_id"] = target.instance_id
message = f"Dealt {actual_damage} damage"
if details.get("knockout"):
message += f" - {target.definition_id} knocked out!"
return EffectResult.success_result(
f"Dealt {actual_damage} damage",
message,
effect_type=EffectType.DAMAGE,
details=details,
)

View File

@ -211,6 +211,98 @@ class TestDealDamage:
assert not result.success
assert "must be positive" in result.message
def test_detects_knockout(self, game_state: GameState, rng: SeededRandom) -> None:
"""
Verify deal_damage sets knockout flag when damage kills target.
This test ensures that damage handlers report knockouts in the result
details, allowing the engine to handle knockout processing.
Charmander has 70 HP, so 70+ damage should knock it out.
"""
ctx = make_context(game_state, rng, params={"amount": 70})
result = resolve_effect("deal_damage", ctx)
assert result.success
assert result.details.get("knockout") is True
assert result.details.get("knockout_pokemon_id") == "charmander-inst"
assert "knocked out" in result.message
def test_no_knockout_flag_when_alive(self, game_state: GameState, rng: SeededRandom) -> None:
"""
Verify deal_damage does NOT set knockout flag when target survives.
Charmander has 70 HP, so 30 damage should leave it alive.
"""
ctx = make_context(game_state, rng, params={"amount": 30})
result = resolve_effect("deal_damage", ctx)
assert result.success
assert "knockout" not in result.details
assert "knocked out" not in result.message
def test_detects_knockout_with_existing_damage(
self, game_state: GameState, rng: SeededRandom
) -> None:
"""
Verify deal_damage detects knockout correctly when target has prior damage.
Charmander has 70 HP. Give it 40 damage first, then 30 more should KO.
"""
target = game_state.players["player2"].get_active_pokemon()
assert target is not None
target.damage = 40 # Pre-existing damage
ctx = make_context(game_state, rng, params={"amount": 30})
result = resolve_effect("deal_damage", ctx)
assert result.success
assert target.damage == 70 # 40 + 30
assert result.details.get("knockout") is True
def test_respects_hp_modifier_for_knockout(
self, game_state: GameState, rng: SeededRandom
) -> None:
"""
Verify deal_damage respects HP modifiers when detecting knockout.
Charmander has 70 base HP. With +30 hp_modifier, effective HP is 100.
70 damage should NOT knock it out; 100 damage should.
"""
target = game_state.players["player2"].get_active_pokemon()
assert target is not None
target.hp_modifier = 30 # Effective HP = 100
# 70 damage should NOT KO
ctx = make_context(game_state, rng, params={"amount": 70})
result = resolve_effect("deal_damage", ctx)
assert result.success
assert target.damage == 70
assert "knockout" not in result.details # Still alive with 30 effective HP remaining
def test_negative_hp_modifier_makes_knockout_easier(
self, game_state: GameState, rng: SeededRandom
) -> None:
"""
Verify deal_damage respects negative HP modifiers for knockout.
Charmander has 70 base HP. With -20 hp_modifier, effective HP is 50.
50 damage should knock it out even though base HP is 70.
"""
target = game_state.players["player2"].get_active_pokemon()
assert target is not None
target.hp_modifier = -20 # Effective HP = 50
ctx = make_context(game_state, rng, params={"amount": 50})
result = resolve_effect("deal_damage", ctx)
assert result.success
assert target.damage == 50
assert result.details.get("knockout") is True
# ============================================================================
# attack_damage Tests (Combat Damage with Modifiers)
@ -739,6 +831,116 @@ class TestAttackDamage:
assert target.damage == 30
assert "weakness" not in result.details
def test_detects_knockout(self, game_state: GameState, rng: SeededRandom) -> None:
"""
Verify attack_damage sets knockout flag when damage kills target.
Charmander has 70 HP, so 70+ damage should knock it out.
The knockout flag allows the engine to handle knockout processing.
"""
ctx = make_context(
game_state,
rng,
source_card_id="pikachu-inst",
params={"amount": 70},
)
result = resolve_effect("attack_damage", ctx)
assert result.success
assert result.details.get("knockout") is True
assert result.details.get("knockout_pokemon_id") == "charmander-inst"
assert "knocked out" in result.message
def test_no_knockout_flag_when_alive(self, game_state: GameState, rng: SeededRandom) -> None:
"""
Verify attack_damage does NOT set knockout flag when target survives.
Charmander has 70 HP, so 30 damage leaves it alive.
"""
ctx = make_context(
game_state,
rng,
source_card_id="pikachu-inst",
params={"amount": 30},
)
result = resolve_effect("attack_damage", ctx)
assert result.success
assert "knockout" not in result.details
assert "knocked out" not in result.message
def test_detects_knockout_with_weakness(self, game_state: GameState, rng: SeededRandom) -> None:
"""
Verify attack_damage detects knockout after weakness multiplier applied.
Charmander has 70 HP and is weak to Lightning. Pikachu deals 40 damage,
which becomes 80 after x2 weakness, causing a knockout.
"""
# Make Charmander weak to Lightning
game_state.card_registry["charmander-001"] = CardDefinition(
id="charmander-001",
name="Charmander",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=70,
pokemon_type=EnergyType.FIRE,
weakness=WeaknessResistance(energy_type=EnergyType.LIGHTNING),
)
ctx = make_context(
game_state,
rng,
source_card_id="pikachu-inst",
params={"amount": 40}, # 40 * 2 = 80 > 70 HP
)
result = resolve_effect("attack_damage", ctx)
assert result.success
assert result.details["final_damage"] == 80
assert result.details.get("knockout") is True
assert "knocked out" in result.message
def test_no_knockout_after_resistance_saves(
self, game_state: GameState, rng: SeededRandom
) -> None:
"""
Verify attack_damage correctly reports no knockout when resistance prevents it.
Charmander has 70 HP with 60 prior damage. Pikachu deals 20 damage,
but Charmander resists Lightning for -30, so final damage is 0.
"""
# Make Charmander resist Lightning
game_state.card_registry["charmander-001"] = CardDefinition(
id="charmander-001",
name="Charmander",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=70,
pokemon_type=EnergyType.FIRE,
resistance=WeaknessResistance(energy_type=EnergyType.LIGHTNING),
)
target = game_state.players["player2"].get_active_pokemon()
assert target is not None
target.damage = 60 # 10 HP remaining
ctx = make_context(
game_state,
rng,
source_card_id="pikachu-inst",
params={"amount": 20}, # 20 - 30 = 0 (floored)
)
result = resolve_effect("attack_damage", ctx)
assert result.success
assert result.details["final_damage"] == 0
assert target.damage == 60 # Unchanged
assert "knockout" not in result.details
# ============================================================================
# heal Tests