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:
parent
554178dc6e
commit
1fbd3d1cfa
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user