Add weakness/resistance support to attack damage calculation

- Add DamageCalculationResult model for transparent damage breakdown
- Implement _calculate_attack_damage with W/R modifiers (additive/multiplicative)
- Add _execute_attack_effect for future effect system integration
- Add _build_attack_message for detailed damage breakdown in messages
- Update _execute_attack to use new calculation pipeline
- Bulbasaur now properly weak to Lightning in walkthrough demo

New features:
- Weakness applies bonus damage (additive +X or multiplicative xN)
- Resistance reduces damage (minimum 0)
- State changes include weakness/resistance details for UI
- Messages show damage breakdown (e.g. 'base 10 +20 weakness')

Tests: 7 new tests covering additive/multiplicative W/R, type matching,
minimum damage floor, knockout triggers, and state change details
This commit is contained in:
Cal Corum 2026-01-26 16:04:41 -06:00
parent 9564916c87
commit 72bd1102df
3 changed files with 747 additions and 36 deletions

View File

@ -41,7 +41,8 @@ from typing import Any
from pydantic import BaseModel, Field
from app.core.config import RulesConfig
from app.core.enums import GameEndReason, StatusCondition, TurnPhase
from app.core.effects import EffectContext, EffectResult, resolve_effect
from app.core.enums import GameEndReason, ModifierMode, StatusCondition, TurnPhase
from app.core.models.actions import (
Action,
AttachEnergyAction,
@ -56,7 +57,7 @@ from app.core.models.actions import (
SelectPrizeAction,
UseAbilityAction,
)
from app.core.models.card import CardDefinition, CardInstance
from app.core.models.card import Attack, CardDefinition, CardInstance
from app.core.models.game_state import GameState, PlayerState
from app.core.rng import RandomProvider, create_rng
from app.core.rules_validator import ValidationResult, validate_action
@ -101,6 +102,31 @@ class GameCreationResult(BaseModel):
message: str = ""
class DamageCalculationResult(BaseModel):
"""Result of calculating attack damage with modifiers.
This captures the full damage calculation pipeline for transparency
and debugging. The message field provides a human-readable breakdown.
Attributes:
base_damage: The attack's base damage value.
after_modifier: Damage after applying attacker's damage_modifier.
after_weakness: Damage after applying weakness (if applicable).
after_resistance: Damage after applying resistance (if applicable).
final_damage: The final damage to apply (minimum 0).
weakness_applied: Details of weakness calculation, or None.
resistance_applied: Details of resistance calculation, or None.
"""
base_damage: int
after_modifier: int
after_weakness: int
after_resistance: int
final_damage: int
weakness_applied: dict[str, Any] | None = None
resistance_applied: dict[str, Any] | None = None
class GameEngine:
"""Main orchestrator for all game operations.
@ -764,25 +790,35 @@ class GameEngine:
if not defender:
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
defender_def = game.get_card_definition(defender.definition_id)
# Calculate damage with weakness/resistance
damage_result = self._calculate_attack_damage(
game=game,
attacker=active,
attacker_def=card_def,
defender=defender,
defender_def=defender_def,
base_damage=attack.damage or 0,
)
# Apply the calculated damage
defender.damage += damage_result.final_damage
# Execute attack effects (if any)
effect_results: list[EffectResult] = []
if attack.effect_id:
effect_result = self._execute_attack_effect(
game=game,
player=player,
attacker=active,
defender=defender,
attack=attack,
)
effect_results.append(effect_result)
# Check for knockout
win_result = None
defender_def = game.get_card_definition(defender.definition_id)
if defender_def and defender_def.hp and defender.is_knocked_out(defender_def.hp):
ko_result = self.turn_manager.process_knockout(
game, defender.instance_id, player.player_id, self.rng
@ -793,10 +829,17 @@ class GameEngine:
# Advance to END phase after attack
self.turn_manager.advance_to_end(game)
# Build message - include confusion heads if applicable
message = f"Attack: {attack.name} dealt {base_damage} damage"
# Build message with damage breakdown
message = self._build_attack_message(attack.name, damage_result, effect_results)
state_changes: list[dict[str, Any]] = [
{"type": "attack", "name": attack.name, "damage": base_damage}
{
"type": "attack",
"name": attack.name,
"base_damage": damage_result.base_damage,
"final_damage": damage_result.final_damage,
"weakness_applied": damage_result.weakness_applied,
"resistance_applied": damage_result.resistance_applied,
}
]
if StatusCondition.CONFUSED in active.status_conditions:
message = f"Confused - flipped heads! {message}"
@ -809,6 +852,183 @@ class GameEngine:
state_changes=state_changes,
)
def _calculate_attack_damage(
self,
game: GameState,
attacker: CardInstance,
attacker_def: CardDefinition | None,
defender: CardInstance,
defender_def: CardDefinition | None,
base_damage: int,
) -> DamageCalculationResult:
"""Calculate attack damage with weakness and resistance.
Applies damage modifiers in order:
1. Base damage from attack
2. Attacker's damage_modifier (from abilities/effects)
3. Weakness (if defender is weak to attacker's type)
4. Resistance (if defender resists attacker's type)
5. Minimum 0
Args:
game: Current game state (for rules config).
attacker: The attacking Pokemon instance.
attacker_def: The attacker's card definition.
defender: The defending Pokemon instance.
defender_def: The defender's card definition.
base_damage: The attack's base damage value.
Returns:
DamageCalculationResult with full damage breakdown.
"""
damage = base_damage
weakness_info: dict[str, Any] | None = None
resistance_info: dict[str, Any] | None = None
# Step 1: Apply attacker's damage modifier
after_modifier = damage
if attacker.damage_modifier != 0:
damage += attacker.damage_modifier
after_modifier = damage
# Step 2: Apply weakness and resistance
after_weakness = damage
after_resistance = damage
if attacker_def and defender_def and attacker_def.pokemon_type:
attacker_type = attacker_def.pokemon_type
combat_config = game.rules.combat
# Check weakness
if defender_def.weakness and defender_def.weakness.energy_type == attacker_type:
weakness = defender_def.weakness
mode = weakness.get_mode(combat_config.weakness_mode)
value = weakness.get_value(combat_config.weakness_value)
if mode == ModifierMode.MULTIPLICATIVE: # noqa: SIM108
damage = damage * value
else: # ADDITIVE
damage = damage + value
after_weakness = damage
weakness_info = {
"type": attacker_type.value,
"mode": mode.value,
"value": value,
}
# Check resistance
if defender_def.resistance and defender_def.resistance.energy_type == attacker_type:
resistance = defender_def.resistance
mode = resistance.get_mode(combat_config.resistance_mode)
value = resistance.get_value(combat_config.resistance_value)
if mode == ModifierMode.MULTIPLICATIVE: # noqa: SIM108
damage = damage * value
else: # ADDITIVE
damage = damage + value
after_resistance = damage
resistance_info = {
"type": attacker_type.value,
"mode": mode.value,
"value": value,
}
# Final damage (minimum 0)
final_damage = max(0, damage)
return DamageCalculationResult(
base_damage=base_damage,
after_modifier=after_modifier,
after_weakness=after_weakness,
after_resistance=after_resistance,
final_damage=final_damage,
weakness_applied=weakness_info,
resistance_applied=resistance_info,
)
def _execute_attack_effect(
self,
game: GameState,
player: PlayerState,
attacker: CardInstance,
defender: CardInstance,
attack: Attack,
) -> EffectResult:
"""Execute an attack's special effect.
Creates an EffectContext and resolves the attack's effect_id.
This is the integration point between the engine and the effect system.
Args:
game: Current game state.
player: The attacking player.
attacker: The attacking Pokemon instance.
defender: The defending Pokemon instance.
attack: The Attack being used (contains effect_id and effect_params).
Returns:
EffectResult from the effect handler.
"""
ctx = EffectContext(
game=game,
source_player_id=player.player_id,
rng=self.rng,
source_card_id=attacker.instance_id,
target_card_id=defender.instance_id,
params=attack.effect_params,
)
return resolve_effect(attack.effect_id, ctx)
def _build_attack_message(
self,
attack_name: str,
damage_result: DamageCalculationResult,
effect_results: list[EffectResult],
) -> str:
"""Build a human-readable attack result message.
Includes damage breakdown showing weakness/resistance effects.
Args:
attack_name: Name of the attack used.
damage_result: The damage calculation result.
effect_results: List of effect results from attack effects.
Returns:
Formatted message string.
"""
parts = [f"Attack: {attack_name} dealt {damage_result.final_damage} damage"]
# Add damage breakdown if modified
if damage_result.final_damage != damage_result.base_damage:
breakdown = f"(base {damage_result.base_damage}"
if damage_result.weakness_applied:
mode = damage_result.weakness_applied["mode"]
value = damage_result.weakness_applied["value"]
if mode == "multiplicative":
breakdown += f" x{value} weakness"
else:
breakdown += f" +{value} weakness"
if damage_result.resistance_applied:
mode = damage_result.resistance_applied["mode"]
value = damage_result.resistance_applied["value"]
if mode == "multiplicative":
breakdown += f" x{value} resistance"
else:
breakdown += f" {value:+d} resistance"
breakdown += ")"
parts.append(breakdown)
# Add effect messages
for effect_result in effect_results:
if effect_result.success and effect_result.message:
parts.append(f"- {effect_result.message}")
return " ".join(parts)
def _execute_retreat(
self,
game: GameState,

View File

@ -26,34 +26,27 @@ if str(backend_dir) not in sys.path:
# =============================================================================
import asyncio
import uuid
from typing import Callable
# Register effect handlers
import app.core.effects.handlers # noqa: F401
from app.core import (
ActionResult,
CardType,
EnergyType,
GameEngine,
GameState,
ModifierMode,
PokemonStage,
PokemonVariant,
RulesConfig,
StatusCondition,
TrainerType,
TurnPhase,
create_rng,
)
from app.core.models import (
Action,
Attack,
AttachEnergyAction,
Attack,
AttackAction,
CardDefinition,
CardInstance,
PassAction,
PlayPokemonAction,
WeaknessResistance,
)
@ -374,7 +367,7 @@ def create_card_definitions() -> dict[str, CardDefinition]:
effect_description="Heal 10 damage from this Pokemon.",
),
],
weakness=WeaknessResistance(energy_type=EnergyType.FIRE),
weakness=WeaknessResistance(energy_type=EnergyType.LIGHTNING, mode=ModifierMode.ADDITIVE, value=20),
resistance=WeaknessResistance(energy_type=EnergyType.WATER, value=-30),
retreat_cost=2,
)
@ -824,7 +817,7 @@ automatically from your energy deck.
if drawn_card:
current_player.hand.add(drawn_card)
card_def = game.get_card_definition(drawn_card.definition_id)
print_action(f"Drawing a card from deck...")
print_action("Drawing a card from deck...")
print_result(f"Drew: {card_def.name if card_def else drawn_card.definition_id}")
# Flip energy
@ -832,11 +825,11 @@ automatically from your energy deck.
if flipped_energy:
current_player.energy_zone.add(flipped_energy)
card_def = game.get_card_definition(flipped_energy.definition_id)
print_action(f"Flipping energy from energy deck...")
print_action("Flipping energy from energy deck...")
print_result(f"Flipped: {card_def.name if card_def else flipped_energy.definition_id}")
game.phase = TurnPhase.MAIN
print_result(f"Advancing to MAIN phase")
print_result("Advancing to MAIN phase")
print(f"\n{bold('Hand size:')} {len(current_player.hand)} cards")
print(f"{bold('Energy zone:')} {len(current_player.energy_zone)} energy available")
@ -932,7 +925,7 @@ if it has enough energy attached.
result = await engine.execute_action(game, game.current_player_id, action)
if result.success:
print_result(f"Attack successful!")
print_result("Attack successful!")
print_result(result.message)
# Show opponent's Pokemon state
@ -942,7 +935,7 @@ if it has enough energy attached.
opp_def = game.get_card_definition(opp_active.definition_id)
max_hp = opp_def.hp + opp_active.hp_modifier if opp_def else 0
print_result(
f"Opponent's {opp_def.name if opp_def else 'Pokemon'}: {max_hp - opp_active.damage}/{max_hp} HP"
f"Opponent's {opp_def.name if opp_def else 'Pokemon'}: {max_hp - opp_active.damage}/{max_hp} HP / CALCULATED HP: {opp_def.hp}"
)
else:
print(error(f"Attack failed: {result.message}"))

View File

@ -2762,3 +2762,501 @@ class TestAttachEnergyFromEnergyZone:
# Energy should be returned to zone
p1 = game_with_energy_zone.players["player1"]
assert "zone-energy" in p1.energy_zone
# =============================================================================
# Weakness and Resistance Tests
# =============================================================================
class TestWeaknessResistance:
"""Tests for weakness and resistance damage calculations.
These tests verify that the engine correctly applies weakness and resistance
modifiers during attack damage calculation. Tests cover:
- Additive weakness (+X damage)
- Multiplicative weakness (xN damage)
- Additive resistance (-X damage)
- Multiplicative resistance (fractional damage)
- Combined weakness + resistance scenarios
- Type matching (only applies when types match)
"""
@pytest.fixture
def lightning_attacker_def(self) -> CardDefinition:
"""Lightning-type attacker with a simple attack."""
return CardDefinition(
id="pikachu-test",
name="Pikachu",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=60,
pokemon_type=EnergyType.LIGHTNING,
attacks=[
Attack(name="Thunder Shock", damage=10, cost=[]),
],
retreat_cost=1,
)
@pytest.fixture
def fire_attacker_def(self) -> CardDefinition:
"""Fire-type attacker with a simple attack."""
return CardDefinition(
id="charmander-test",
name="Charmander",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=70,
pokemon_type=EnergyType.FIRE,
attacks=[
Attack(name="Ember", damage=30, cost=[]),
],
retreat_cost=1,
)
@pytest.fixture
def grass_weak_to_lightning_def(self) -> CardDefinition:
"""Grass Pokemon with additive Lightning weakness (+20)."""
from app.core.enums import ModifierMode
from app.core.models.card import WeaknessResistance
return CardDefinition(
id="bulbasaur-test",
name="Bulbasaur",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=70,
pokemon_type=EnergyType.GRASS,
attacks=[Attack(name="Vine Whip", damage=20, cost=[])],
weakness=WeaknessResistance(
energy_type=EnergyType.LIGHTNING,
mode=ModifierMode.ADDITIVE,
value=20,
),
retreat_cost=1,
)
@pytest.fixture
def water_weak_to_lightning_x2_def(self) -> CardDefinition:
"""Water Pokemon with multiplicative Lightning weakness (x2)."""
from app.core.enums import ModifierMode
from app.core.models.card import WeaknessResistance
return CardDefinition(
id="squirtle-test",
name="Squirtle",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=60,
pokemon_type=EnergyType.WATER,
attacks=[Attack(name="Bubble", damage=20, cost=[])],
weakness=WeaknessResistance(
energy_type=EnergyType.LIGHTNING,
mode=ModifierMode.MULTIPLICATIVE,
value=2,
),
retreat_cost=1,
)
@pytest.fixture
def grass_resists_water_def(self) -> CardDefinition:
"""Grass Pokemon with Water resistance (-30)."""
from app.core.enums import ModifierMode
from app.core.models.card import WeaknessResistance
return CardDefinition(
id="tangela-test",
name="Tangela",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=80,
pokemon_type=EnergyType.GRASS,
attacks=[Attack(name="Bind", damage=20, cost=[])],
resistance=WeaknessResistance(
energy_type=EnergyType.WATER,
mode=ModifierMode.ADDITIVE,
value=-30,
),
retreat_cost=2,
)
@pytest.fixture
def grass_weak_and_resistant_def(self) -> CardDefinition:
"""Grass Pokemon with Fire weakness and Water resistance."""
from app.core.enums import ModifierMode
from app.core.models.card import WeaknessResistance
return CardDefinition(
id="oddish-test",
name="Oddish",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=50,
pokemon_type=EnergyType.GRASS,
attacks=[Attack(name="Absorb", damage=10, cost=[])],
weakness=WeaknessResistance(
energy_type=EnergyType.FIRE,
mode=ModifierMode.ADDITIVE,
value=20,
),
resistance=WeaknessResistance(
energy_type=EnergyType.WATER,
mode=ModifierMode.ADDITIVE,
value=-30,
),
retreat_cost=1,
)
@pytest.fixture
def energy_def(self) -> CardDefinition:
"""Basic Lightning energy."""
return CardDefinition(
id="lightning-energy",
name="Lightning Energy",
card_type=CardType.ENERGY,
energy_type=EnergyType.LIGHTNING,
energy_provides=[EnergyType.LIGHTNING],
)
def _create_battle_game(
self,
attacker_def: CardDefinition,
defender_def: CardDefinition,
energy_def: CardDefinition,
) -> tuple[GameEngine, GameState]:
"""Helper to create a game ready for attack testing.
Sets up:
- Player1 has attacker as active, in attack phase
- Player2 has defender as active
- Registry with all card definitions
"""
rng = SeededRandom(seed=42)
engine = GameEngine(rules=RulesConfig(), rng=rng)
# Create card instances
attacker = CardInstance(instance_id="attacker-1", definition_id=attacker_def.id)
defender = CardInstance(instance_id="defender-1", definition_id=defender_def.id)
# Create minimal decks (pad with attacker copies)
p1_deck = [
CardInstance(instance_id=f"p1-card-{i}", definition_id=attacker_def.id)
for i in range(40)
]
p2_deck = [
CardInstance(instance_id=f"p2-card-{i}", definition_id=defender_def.id)
for i in range(40)
]
p1_energy = [
CardInstance(instance_id=f"p1-energy-{i}", definition_id=energy_def.id)
for i in range(20)
]
p2_energy = [
CardInstance(instance_id=f"p2-energy-{i}", definition_id=energy_def.id)
for i in range(20)
]
result = engine.create_game(
player_ids=["player1", "player2"],
decks={"player1": p1_deck, "player2": p2_deck},
energy_decks={"player1": p1_energy, "player2": p2_energy},
card_registry={
attacker_def.id: attacker_def,
defender_def.id: defender_def,
energy_def.id: energy_def,
},
)
game = result.game
# Set up the battlefield
p1 = game.players["player1"]
p2 = game.players["player2"]
# Clear active zones and place our test Pokemon
p1.active.clear()
p2.active.clear()
p1.active.add(attacker)
p2.active.add(defender)
# Set game state for attack
game.phase = TurnPhase.ATTACK
game.current_player_id = "player1"
game.turn_number = 1
return engine, game
@pytest.mark.asyncio
async def test_weakness_additive_increases_damage(
self,
lightning_attacker_def: CardDefinition,
grass_weak_to_lightning_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that additive weakness (+20) correctly increases damage.
Setup: Pikachu (Lightning) attacks Bulbasaur (weak to Lightning +20)
Attack: Thunder Shock (10 damage)
Expected: 10 base + 20 weakness = 30 damage
"""
engine, game = self._create_battle_game(
lightning_attacker_def,
grass_weak_to_lightning_def,
energy_def,
)
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
defender = game.players["player2"].get_active_pokemon()
assert defender.damage == 30 # 10 base + 20 weakness
assert "weakness" in result.message.lower()
@pytest.mark.asyncio
async def test_weakness_multiplicative_doubles_damage(
self,
lightning_attacker_def: CardDefinition,
water_weak_to_lightning_x2_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that multiplicative weakness (x2) correctly doubles damage.
Setup: Pikachu (Lightning) attacks Squirtle (weak to Lightning x2)
Attack: Thunder Shock (10 damage)
Expected: 10 base x 2 = 20 damage
"""
engine, game = self._create_battle_game(
lightning_attacker_def,
water_weak_to_lightning_x2_def,
energy_def,
)
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
defender = game.players["player2"].get_active_pokemon()
assert defender.damage == 20 # 10 base x 2 weakness
assert "weakness" in result.message.lower()
@pytest.mark.asyncio
async def test_resistance_reduces_damage(
self,
energy_def: CardDefinition,
grass_resists_water_def: CardDefinition,
):
"""
Test that additive resistance (-30) correctly reduces damage.
Setup: Water attacker attacks Tangela (resists Water -30)
Attack: 30 damage
Expected: 30 base - 30 resistance = 0 damage (minimum 0)
"""
# Create a Water attacker
water_attacker_def = CardDefinition(
id="psyduck-test",
name="Psyduck",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=50,
pokemon_type=EnergyType.WATER,
attacks=[Attack(name="Water Gun", damage=30, cost=[])],
retreat_cost=1,
)
engine, game = self._create_battle_game(
water_attacker_def,
grass_resists_water_def,
energy_def,
)
# Add water attacker to registry
game.card_registry[water_attacker_def.id] = water_attacker_def
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
defender = game.players["player2"].get_active_pokemon()
assert defender.damage == 0 # 30 base - 30 resistance = 0 (minimum)
assert "resistance" in result.message.lower()
@pytest.mark.asyncio
async def test_no_weakness_normal_damage(
self,
fire_attacker_def: CardDefinition,
grass_weak_to_lightning_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that no weakness is applied when types don't match.
Setup: Charmander (Fire) attacks Bulbasaur (weak to Lightning, not Fire)
Attack: Ember (30 damage)
Expected: 30 damage (no weakness bonus)
"""
engine, game = self._create_battle_game(
fire_attacker_def,
grass_weak_to_lightning_def,
energy_def,
)
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
defender = game.players["player2"].get_active_pokemon()
assert defender.damage == 30 # Just base damage, no weakness
assert "weakness" not in result.message.lower()
@pytest.mark.asyncio
async def test_damage_minimum_zero(
self,
energy_def: CardDefinition,
):
"""
Test that damage cannot go below zero with resistance.
Setup: Attacker deals 10 damage, defender has -30 resistance
Expected: 0 damage (not negative)
"""
from app.core.enums import ModifierMode
from app.core.models.card import WeaknessResistance
# Create a weak attacker
weak_attacker_def = CardDefinition(
id="magikarp-test",
name="Magikarp",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=30,
pokemon_type=EnergyType.WATER,
attacks=[Attack(name="Splash", damage=10, cost=[])],
retreat_cost=1,
)
# Create defender with high resistance (using BASIC for simplicity)
high_resist_def = CardDefinition(
id="tangela-test-2",
name="Tangela",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=80,
pokemon_type=EnergyType.GRASS,
attacks=[Attack(name="Bind", damage=20, cost=[])],
resistance=WeaknessResistance(
energy_type=EnergyType.WATER,
mode=ModifierMode.ADDITIVE,
value=-30,
),
retreat_cost=2,
)
engine, game = self._create_battle_game(
weak_attacker_def,
high_resist_def,
energy_def,
)
game.card_registry[weak_attacker_def.id] = weak_attacker_def
game.card_registry[high_resist_def.id] = high_resist_def
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
defender = game.players["player2"].get_active_pokemon()
assert defender.damage == 0 # 10 - 30 = -20, but minimum 0
@pytest.mark.asyncio
async def test_weakness_causes_knockout(
self,
lightning_attacker_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that weakness bonus damage can cause a knockout.
Setup: Pikachu (60 HP attacker) attacks 30 HP defender weak to Lightning (+20)
Attack: 10 damage + 20 weakness = 30 damage, should knock out 30 HP defender
"""
from app.core.enums import ModifierMode
from app.core.models.card import WeaknessResistance
# Create a low HP defender weak to Lightning
low_hp_defender_def = CardDefinition(
id="voltorb-test",
name="Voltorb",
card_type=CardType.POKEMON,
stage=PokemonStage.BASIC,
hp=30, # Will be KO'd by 30 damage
pokemon_type=EnergyType.LIGHTNING,
attacks=[Attack(name="Tackle", damage=10, cost=[])],
weakness=WeaknessResistance(
energy_type=EnergyType.LIGHTNING, # Weak to itself for test
mode=ModifierMode.ADDITIVE,
value=20,
),
retreat_cost=1,
)
engine, game = self._create_battle_game(
lightning_attacker_def,
low_hp_defender_def,
energy_def,
)
game.card_registry[low_hp_defender_def.id] = low_hp_defender_def
# Track initial score
initial_score = game.players["player1"].score
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
# Knockout should have happened - check via:
# 1. Message contains weakness
assert "weakness" in result.message.lower()
# 2. Attacker scored a point (knockout awards points)
assert game.players["player1"].score > initial_score
# 3. The attack state change shows final damage of 30
attack_change = next(
(sc for sc in result.state_changes if sc.get("type") == "attack"),
None,
)
assert attack_change is not None
assert attack_change["final_damage"] == 30 # 10 base + 20 weakness
@pytest.mark.asyncio
async def test_state_changes_include_weakness_info(
self,
lightning_attacker_def: CardDefinition,
grass_weak_to_lightning_def: CardDefinition,
energy_def: CardDefinition,
):
"""
Test that state_changes includes weakness/resistance information.
This is important for UI animations and logging.
"""
engine, game = self._create_battle_game(
lightning_attacker_def,
grass_weak_to_lightning_def,
energy_def,
)
action = AttackAction(attack_index=0)
result = await engine.execute_action(game, "player1", action)
assert result.success
# Find the attack state change
attack_change = next(
(sc for sc in result.state_changes if sc.get("type") == "attack"),
None,
)
assert attack_change is not None
assert attack_change["base_damage"] == 10
assert attack_change["final_damage"] == 30
assert attack_change["weakness_applied"] is not None
assert attack_change["weakness_applied"]["type"] == "lightning"