160 lines
6.3 KiB
Python
160 lines
6.3 KiB
Python
"""Tests for combat priority checkpoints (Issue #86 Item 1).
|
|
|
|
Validates CombatState lifecycle, split combat methods,
|
|
priority windows, first-strike ordering, and instant-speed
|
|
interaction during combat.
|
|
"""
|
|
import pytest
|
|
from commander_ai_lab.sim.models import (
|
|
Card, CombatState, Phase, Player, PlayerStats, SimState,
|
|
)
|
|
from commander_ai_lab.sim.engine import GameEngine
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────
|
|
|
|
def _make_creature(name, power, toughness, card_id, owner=0, keywords=None, is_commander=False):
|
|
"""Build a minimal creature Card for testing."""
|
|
return Card(
|
|
name=name,
|
|
type_line="Creature",
|
|
power=str(power),
|
|
toughness=str(toughness),
|
|
pt=f"{power}/{toughness}",
|
|
id=card_id,
|
|
owner_id=owner,
|
|
keywords=keywords or [],
|
|
is_commander=is_commander,
|
|
turn_played=-1,
|
|
)
|
|
|
|
|
|
def _two_player_sim(p0_creatures, p1_creatures, p0_life=40, p1_life=40):
|
|
"""Build a minimal 2-player SimState with creatures on the battlefield."""
|
|
sim = SimState(
|
|
players=[
|
|
Player(name="Attacker", life=p0_life, owner_id=0, stats=PlayerStats()),
|
|
Player(name="Defender", life=p1_life, owner_id=1, stats=PlayerStats()),
|
|
],
|
|
battlefields=[list(p0_creatures), list(p1_creatures)],
|
|
turn=2,
|
|
)
|
|
return sim
|
|
|
|
|
|
# ── Tests ───────────────────────────────────────────────────────
|
|
|
|
class TestCombatState:
|
|
"""CombatState dataclass lifecycle."""
|
|
|
|
def test_combat_state_defaults(self):
|
|
cs = CombatState()
|
|
assert cs.defending_seat == -1
|
|
assert cs.attackers == {}
|
|
assert cs.blockers == {}
|
|
assert cs.player_damage == {}
|
|
assert cs.first_strike_resolved is False
|
|
assert cs.active is False
|
|
|
|
def test_combat_state_on_simstate(self):
|
|
sim = SimState()
|
|
assert sim.combat is None
|
|
sim.combat = CombatState(active=True)
|
|
assert sim.combat.active is True
|
|
sim.combat = None
|
|
assert sim.combat is None
|
|
|
|
|
|
class TestAssignAttackers:
|
|
"""Engine.assign_attackers() populates CombatState."""
|
|
|
|
def test_assigns_and_taps(self):
|
|
atk = _make_creature("Bear", 3, 3, 100)
|
|
sim = _two_player_sim([atk], [])
|
|
engine = GameEngine()
|
|
combat = engine.assign_attackers(sim, 0)
|
|
assert combat is not None
|
|
assert combat.active is True
|
|
assert 100 in combat.attackers
|
|
assert atk.tapped is True
|
|
assert sim.combat is combat
|
|
|
|
def test_no_attack_returns_none(self):
|
|
sim = _two_player_sim([], []) # no creatures
|
|
engine = GameEngine()
|
|
assert engine.assign_attackers(sim, 0) is None
|
|
assert sim.combat is None
|
|
|
|
|
|
class TestResolveCombatDamage:
|
|
"""Engine.resolve_combat_damage() applies damage from CombatState."""
|
|
|
|
def test_unblocked_attacker_deals_damage(self):
|
|
atk = _make_creature("Bear", 3, 3, 100)
|
|
sim = _two_player_sim([atk], [], p1_life=20)
|
|
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
|
|
sim.combat = combat
|
|
engine = GameEngine()
|
|
engine.resolve_combat_damage(sim, 0, combat)
|
|
assert sim.players[1].life == 17 # 20 - 3
|
|
|
|
def test_attacker_removed_before_damage_deals_nothing(self):
|
|
"""Simulates instant-speed removal of attacker after declare attackers."""
|
|
atk = _make_creature("Bear", 3, 3, 100)
|
|
sim = _two_player_sim([atk], [], p1_life=20)
|
|
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
|
|
sim.combat = combat
|
|
# Simulate instant-speed removal: remove attacker before damage
|
|
sim.remove_from_battlefield(100)
|
|
engine = GameEngine()
|
|
engine.resolve_combat_damage(sim, 0, combat)
|
|
assert sim.players[1].life == 20 # no damage dealt
|
|
|
|
def test_first_strike_only_skips_non_fs(self):
|
|
fs = _make_creature("Knight", 2, 2, 200, keywords=["first_strike"])
|
|
normal = _make_creature("Bear", 3, 3, 201)
|
|
sim = _two_player_sim([fs, normal], [], p1_life=20)
|
|
combat = CombatState(
|
|
defending_seat=1,
|
|
attackers={200: 1, 201: 1},
|
|
active=True,
|
|
)
|
|
sim.combat = combat
|
|
engine = GameEngine()
|
|
# First-strike step: only the knight deals damage
|
|
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=True)
|
|
assert sim.players[1].life == 18 # 20 - 2 (knight only)
|
|
assert combat.first_strike_resolved is True
|
|
# Normal damage step: bear deals damage, knight skipped
|
|
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=False)
|
|
assert sim.players[1].life == 15 # 18 - 3 (bear only)
|
|
|
|
def test_combat_state_cleared_after_end_combat(self):
|
|
"""Verifies sim.combat is None after combat cleanup."""
|
|
sim = SimState()
|
|
sim.combat = CombatState(active=True)
|
|
# Simulate end-of-combat cleanup
|
|
sim.combat = None
|
|
assert sim.combat is None
|
|
|
|
def test_commander_damage_attributed(self):
|
|
"""Commander attacker routes damage through commander_damage_by_card."""
|
|
cmd = _make_creature("Atraxa", 4, 4, 300, is_commander=True)
|
|
sim = _two_player_sim([cmd], [], p1_life=40)
|
|
combat = CombatState(defending_seat=1, attackers={300: 1}, active=True)
|
|
sim.combat = combat
|
|
engine = GameEngine()
|
|
engine.resolve_combat_damage(sim, 0, combat)
|
|
assert sim.players[1].life == 36 # 40 - 4
|
|
assert sim.players[1].commander_damage_by_card.get((0, "Atraxa"), 0) == 4
|
|
|
|
|
|
class TestResponsePhasesCleanup:
|
|
"""Verify BEGIN_COMBAT and END_COMBAT removed from RESPONSE_PHASES."""
|
|
|
|
def test_no_duplicate_apnap_begin_end_combat(self):
|
|
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
|
|
assert "begin_combat" not in RESPONSE_PHASES
|
|
assert "end_combat" not in RESPONSE_PHASES
|
|
assert "upkeep" in RESPONSE_PHASES
|
|
assert "end_step" in RESPONSE_PHASES |