Files
commander-ai-lab/tests/sim/test_direct_damage.py
T
KoalaTrapLordandClaude Code 85aa15445b feat: route direct-damage spells through centralized deal_damage() (#86) (#90)
Add centralized deal_damage() method to GameEngine that handles life
reduction, stat tracking, commander damage (both per-seat and per-card),
and elimination checks. Route both burn spells and combat damage through
this unified path, building on the dual-dict commander damage model
merged in PR #89.

- Add is_direct_damage / direct_damage_amount fields to Card
- Add burn spell detection via known-card table + oracle regex fallback
- Route burn spells in _play_spells() through deal_damage()
- Refactor _resolve_combat() to route per-attacker damage through deal_damage()
- Add 38 regression tests covering deal_damage(), burn detection, spell routing,
  combat routing, and full-game integration

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-03-22 22:27:37 -04:00

540 lines
22 KiB
Python

"""Regression tests for direct-damage spell routing (Issue #86).
Tests that burn spells and similar direct-damage effects are routed
through the centralized deal_damage() path, correctly update
life totals / stats, and integrate with commander damage tracking.
Adapted to work with the dual-dict commander damage model from PR #89:
- commander_damage_received[seat] = per-seat aggregate
- commander_damage_by_card[(seat, card_name)] = per-card breakdown
"""
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(name="Bear", pt="2/2", cmc=2, oracle_text="", **kwargs) -> Card:
"""Build a creature Card with optional oracle text."""
c = Card(
name=name,
type_line="Creature",
cmc=cmc,
pt=pt,
power=pt.split("/")[0],
toughness=pt.split("/")[1],
oracle_text=oracle_text,
**kwargs,
)
enrich_card(c)
return c
def _make_land(name="Forest") -> Card:
c = Card(name=name)
enrich_card(c)
return c
def _make_burn(name="Lightning Bolt", cmc=1, oracle_text="Lightning Bolt deals 3 damage to any target.") -> Card:
c = Card(name=name, type_line="Instant", cmc=cmc, oracle_text=oracle_text)
enrich_card(c)
return c
def _setup_two_player(
p0_hand=None, p1_hand=None, p0_life=40, p1_life=40,
p0_battlefield=None, p1_battlefield=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 2-player SimState with controlled hands/battlefields."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
for i, (hand, life, bf) in enumerate([
(p0_hand or [], p0_life, p0_battlefield or []),
(p1_hand or [], p1_life, p1_battlefield or []),
]):
p = Player(
name=f"Player {i}",
life=life,
owner_id=i,
hand=list(hand),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(2)
for seat, bf_cards in enumerate([p0_battlefield or [], p1_battlefield or []]):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
def _setup_four_player(
hands=None, lives=None, battlefields=None,
) -> tuple[GameEngine, SimState]:
"""Set up a 4-player SimState."""
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
hands = hands or [[] for _ in range(4)]
lives = lives or [40, 40, 40, 40]
battlefields = battlefields or [[] for _ in range(4)]
for i in range(4):
p = Player(
name=f"Player {i}",
life=lives[i],
owner_id=i,
hand=list(hands[i]),
library=[_make_land() for _ in range(30)],
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(p)
sim.init_battlefields(4)
for seat, bf_cards in enumerate(battlefields):
for c in bf_cards:
c.owner_id = seat
c.id = sim.next_card_id
sim.next_card_id += 1
c.turn_played = -1
sim.add_to_battlefield(seat, c)
return engine, sim
# ══════════════════════════════════════════════════════════════
# deal_damage() centralized method tests
# ══════════════════════════════════════════════════════════════
class TestDealDamage:
"""Tests for GameEngine.deal_damage() static method."""
def test_basic_damage_reduces_life(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert dealt == 5
assert sim.players[1].life == 15
def test_zero_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 0, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_negative_damage_is_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, -3, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 20
def test_damage_updates_stats(self):
engine, sim = _setup_two_player()
engine.deal_damage(sim, 7, target_seat=1, source_seat=0)
assert sim.players[0].stats.damage_dealt == 7
assert sim.players[1].stats.damage_received == 7
def test_damage_eliminates_at_zero(self):
engine, sim = _setup_two_player(p1_life=3)
engine.deal_damage(sim, 3, target_seat=1, source_seat=0)
assert sim.players[1].life == 0
assert sim.players[1].eliminated is True
def test_damage_eliminates_below_zero(self):
engine, sim = _setup_two_player(p1_life=2)
engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert sim.players[1].life == -3
assert sim.players[1].eliminated is True
def test_damage_to_eliminated_player_is_noop(self):
engine, sim = _setup_two_player(p1_life=0)
sim.players[1].eliminated = True
dealt = engine.deal_damage(sim, 10, target_seat=1)
assert dealt == 0
assert sim.players[1].life == 0
def test_events_logged(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, source_seat=0,
events=events, label="Bolt deals 3")
assert len(events) == 1
assert "Bolt deals 3" in events[0]
assert "Player 1 now at 17 life" in events[0]
def test_no_label_means_no_event(self):
engine, sim = _setup_two_player(p1_life=20)
events: list[str] = []
engine.deal_damage(sim, 3, target_seat=1, events=events)
assert len(events) == 0
# ══════════════════════════════════════════════════════════════
# Commander damage tracking through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCommanderDamageTracking:
"""Tests for commander damage tracked by deal_damage() using
the dual-dict model: per-seat aggregate + per-card breakdown."""
def test_commander_damage_tracked_both_dicts(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0, is_combat=True)
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_commander_damage_accumulates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_received[0] == 8
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 8
def test_commander_21_damage_eliminates(self):
engine, sim = _setup_two_player()
cmd = _make_creature("Atraxa", pt="7/7", is_commander=True)
for _ in range(3):
engine.deal_damage(sim, 7, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 21
assert sim.players[1].eliminated is True
def test_partner_commanders_tracked_separately(self):
engine, sim = _setup_two_player()
cmd_a = _make_creature("Thrasios", pt="1/3", is_commander=True)
cmd_b = _make_creature("Tymna", pt="2/2", is_commander=True)
# 15 from Thrasios, 15 from Tymna — neither hits 21 alone
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_a, source_seat=0)
for _ in range(15):
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_b, source_seat=0)
# Per-card: each under 21
assert sim.players[1].commander_damage_by_card[(0, "Thrasios")] == 15
assert sim.players[1].commander_damage_by_card[(0, "Tymna")] == 15
# Per-seat aggregate: 30 total
assert sim.players[1].commander_damage_received[0] == 30
# Life is 10, but not eliminated by commander damage (no single source >= 21)
assert sim.players[1].life == 10
assert sim.players[1].is_dead_to_commander_damage() is False
def test_non_commander_damage_not_tracked(self):
engine, sim = _setup_two_player()
regular = _make_creature("Bear", pt="2/2")
engine.deal_damage(sim, 2, target_seat=1, source_card=regular, source_seat=0)
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_is_dead_to_commander_damage(self):
p = Player(name="Test", life=40)
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 20
p.commander_damage_received[0] = 20
assert p.is_dead_to_commander_damage() is False
p.commander_damage_by_card[(0, "Kozilek")] = 21
p.commander_damage_received[0] = 21
assert p.is_dead_to_commander_damage() is True
def test_commander_damage_eliminates_despite_high_life(self):
"""A player at 100 life should still die to 21 commander damage."""
engine, sim = _setup_two_player(p1_life=100)
cmd = _make_creature("Voltron", pt="21/21", is_commander=True)
engine.deal_damage(sim, 21, target_seat=1, source_card=cmd, source_seat=0)
assert sim.players[1].life == 79
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Direct-damage spell detection (rules.py)
# ══════════════════════════════════════════════════════════════
class TestDirectDamageDetection:
"""Tests for is_direct_damage detection in enrich_card/rules."""
def test_lightning_bolt_detected(self):
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1,
oracle_text="Lightning Bolt deals 3 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_shock_detected(self):
c = Card(name="Shock", type_line="Instant", cmc=1,
oracle_text="Shock deals 2 damage to any target.")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 2
def test_known_burn_by_name(self):
"""Known burn spells should be detected even without oracle text."""
c = Card(name="Lightning Bolt")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_chain_lightning_detected(self):
c = Card(name="Chain Lightning")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 3
def test_boros_charm_detected(self):
c = Card(name="Boros Charm")
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 4
def test_oracle_text_detection(self):
"""An unknown card with direct-damage oracle text should be detected."""
c = Card(
name="Custom Burn Spell",
type_line="Instant",
cmc=2,
oracle_text="Custom Burn Spell deals 5 damage to target creature or player.",
)
enrich_card(c)
assert c.is_direct_damage is True
assert c.direct_damage_amount == 5
def test_board_wipe_not_flagged(self):
"""Board wipes should not be flagged as direct damage."""
c = Card(name="Blasphemous Act", type_line="Sorcery", cmc=9,
oracle_text="Blasphemous Act deals 13 damage to each creature.")
enrich_card(c)
assert c.is_board_wipe is True
assert c.is_direct_damage is False
def test_creature_not_flagged(self):
"""A vanilla creature should not be flagged as direct damage."""
c = _make_creature("Grizzly Bears", pt="2/2")
assert c.is_direct_damage is False
def test_removal_spell_not_flagged_as_direct_damage(self):
"""Pure removal (destroy target) should not become direct damage."""
c = Card(name="Murder", type_line="Instant", cmc=3,
oracle_text="Destroy target creature.")
enrich_card(c)
assert c.is_removal is True
assert c.is_direct_damage is False
# ══════════════════════════════════════════════════════════════
# Direct-damage routing through _play_spells()
# ══════════════════════════════════════════════════════════════
class TestDirectDamageSpellRouting:
"""Tests that burn spells resolve through deal_damage() during gameplay."""
def test_burn_spell_reduces_opponent_life(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
# Lightning Bolt: 3 damage
assert sim.players[1].life == 17
assert sim.players[0].stats.damage_dealt == 3
assert sim.players[1].stats.damage_received == 3
def test_burn_spell_goes_to_graveyard(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
# Bolt should be in graveyard
assert any(c.name == "Lightning Bolt" for c in sim.players[0].graveyard)
def test_burn_spell_eliminates_low_life_opponent(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=2,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == -1
assert sim.players[1].eliminated is True
def test_burn_targets_weakest_opponent(self):
"""In multiplayer, burn should target the weakest opponent."""
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_four_player(
hands=[[bolt], [], [], []],
lives=[40, 30, 10, 20],
battlefields=[lands, [], [], []],
)
engine._play_spells(sim, 0, available_mana=3)
# Player 2 (life=10) is weakest, should be targeted
assert sim.players[2].life == 7
def test_burn_events_logged(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
p1_life=20,
)
events: list[str] = []
engine._play_spells(sim, 0, available_mana=3, events=events)
burn_events = [e for e in events if "Lightning Bolt" in e and "damage" in e]
assert len(burn_events) >= 1
def test_burn_spell_taps_lands(self):
bolt = _make_burn("Lightning Bolt")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[bolt],
p0_battlefield=lands,
)
engine._play_spells(sim, 0, available_mana=3)
tapped_lands = [c for c in sim.get_battlefield(0) if c.is_land() and c.tapped]
assert len(tapped_lands) == 1 # CMC 1
def test_shock_deals_2(self):
shock = _make_burn("Shock", cmc=1, oracle_text="Shock deals 2 damage to any target.")
lands = [_make_land() for _ in range(3)]
engine, sim = _setup_two_player(
p0_hand=[shock],
p0_battlefield=lands,
p1_life=20,
)
engine._play_spells(sim, 0, available_mana=3)
assert sim.players[1].life == 18
# ══════════════════════════════════════════════════════════════
# Combat damage routing through deal_damage()
# ══════════════════════════════════════════════════════════════
class TestCombatDamageRouting:
"""Tests that combat damage routes through deal_damage()."""
def test_unblocked_creature_deals_damage(self):
bear = _make_creature("Bear", pt="2/2")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=20,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 18
assert sim.players[0].stats.damage_dealt == 2
assert sim.players[1].stats.damage_received == 2
def test_commander_combat_damage_tracked(self):
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 36
# Per-seat aggregate
assert sim.players[1].commander_damage_received[0] == 4
# Per-card breakdown
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
def test_non_commander_combat_damage_not_tracked_as_commander(self):
bear = _make_creature("Bear", pt="3/3")
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[bear] + lands,
p1_life=40,
)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].life == 37
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].commander_damage_by_card == {}
def test_commander_lethal_at_21(self):
"""A commander dealing 21 damage should eliminate even at high life."""
cmd = _make_creature("Voltron", pt="7/7", is_commander=True)
lands = [_make_land() for _ in range(5)]
engine, sim = _setup_two_player(
p0_battlefield=[cmd] + lands,
p1_life=100,
)
# Simulate 3 combat rounds
for t in range(3):
if sim.players[1].eliminated:
break
# Reset attacker for next combat
cmd.tapped = False
cmd.turn_played = -1
engine._resolve_combat(sim, 0, turn=t + 1)
assert sim.players[1].commander_damage_by_card[(0, "Voltron")] == 21
assert sim.players[1].eliminated is True
# ══════════════════════════════════════════════════════════════
# Full-game integration with burn spells
# ══════════════════════════════════════════════════════════════
class TestFullGameWithBurn:
"""Integration tests running full games with burn spells in decks."""
def test_game_with_burn_completes(self):
"""A game with burn spells in decks should complete without error."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(8)]
+ [Card(name="Shock") for _ in range(8)]
+ [Card(name="Forest") for _ in range(24)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
)
deck_b = (
[Card(name="Forest") for _ in range(24)]
+ [Card(name="Serra Angel") for _ in range(10)]
+ [Card(name="Grizzly Bears") for _ in range(20)]
+ [Card(name="Murder") for _ in range(6)]
)
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25, record_log=True)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Midrange",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_burn_damage_appears_in_stats(self):
"""Burn spell damage should be reflected in player stats."""
deck_a = (
[Card(name="Lightning Bolt") for _ in range(15)]
+ [Card(name="Mountain") for _ in range(45)]
)
deck_b = [Card(name="Forest") for _ in range(60)]
for c in deck_a + deck_b:
enrich_card(c)
engine = GameEngine(max_turns=25)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Burn",
name_b="Lands",
)
# Player A should have dealt some damage from burn spells
pa_stats = result.player_a_stats
assert pa_stats is not None
assert pa_stats.damage_dealt > 0