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:
parent
9564916c87
commit
72bd1102df
@ -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,
|
||||
|
||||
@ -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}"))
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user