Files
commander-ai-lab/tests/test_combat_priority.py
T

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