Files
commander-ai-lab/tests/sim/test_triggered_damage.py
T
Claude AgentandClaude Opus 4.6 881fb747a3 feat: add triggered-ability damage routing through centralized deal_damage()
Implements source-aware triggered-ability damage for ETB and dies triggers
as part of the ongoing issue #86 damage-routing work.

Changes:
- Add deal_damage() centralized static method to GameEngine that handles
  life reduction, stats tracking, elimination checks, and event logging
- Add etb_damage/dies_damage fields and target modes to Card model
- Add _detect_trigger_damage() heuristic in rules.py to parse ETB/dies
  damage amounts and target modes from oracle text
- Wire ETB triggers to fire when creatures enter via _play_spells and
  commander casting from command zone
- Wire dies triggers to fire in _send_to_graveyard for removal, board
  wipes, and combat deaths
- Route combat damage through deal_damage() for consistent stat tracking
- Add 24 targeted regression tests covering deal_damage unit behavior,
  trigger detection, ETB/dies firing, combat routing, and integration

All 355 existing tests continue to pass with 0 regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 01:31:35 +00:00

449 lines
19 KiB
Python

"""Regression tests for triggered-ability damage routing.
Tests that ETB and dies damage triggers fire through the centralized
deal_damage() path, correctly update life/stats, and handle edge cases
like each-opponent targeting, board wipes, removal kills, and combat deaths.
"""
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 for trigger detection."""
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 _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)
# Place battlefield cards
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 # not summoning sick
sim.add_to_battlefield(seat, c)
return engine, sim
# ══════════════════════════════════════════════════════════════
# deal_damage() centralized method tests
# ══════════════════════════════════════════════════════════════
class TestDealDamage:
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_damage_updates_stats(self):
engine, sim = _setup_two_player()
engine.deal_damage(sim, 3, target_seat=1, source_seat=0)
assert sim.players[0].stats.damage_dealt == 3
assert sim.players[1].stats.damage_received == 3
def test_lethal_damage_eliminates(self):
engine, sim = _setup_two_player(p1_life=3)
engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert sim.players[1].eliminated is True
assert sim.players[1].life == -2
def test_zero_damage_noop(self):
engine, sim = _setup_two_player(p1_life=20)
dealt = engine.deal_damage(sim, 0, target_seat=1, source_seat=0)
assert dealt == 0
assert sim.players[1].life == 20
def test_damage_to_eliminated_player_noop(self):
engine, sim = _setup_two_player(p1_life=0)
sim.players[1].eliminated = True
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
assert dealt == 0
def test_damage_logs_events(self):
engine, sim = _setup_two_player(p1_life=20)
events = []
engine.deal_damage(sim, 4, target_seat=1, source_seat=0,
events=events, label="Test hit deals 4")
assert len(events) == 1
assert "Test hit deals 4" in events[0]
assert "16 life" in events[0]
# ══════════════════════════════════════════════════════════════
# Trigger detection (enrich_card) tests
# ══════════════════════════════════════════════════════════════
class TestTriggerDetection:
def test_etb_damage_detected(self):
"""Cards like 'Flametongue Kavu' style ETB damage should be detected."""
c = _make_creature(
name="Flametongue Kavu", pt="4/2", cmc=4,
oracle_text="When Flametongue Kavu enters the battlefield, it deals 4 damage to target creature.",
)
assert c.etb_damage == 4
assert c.etb_damage_target in ("opponent", "any_target")
def test_dies_damage_detected(self):
"""Cards with dies-trigger damage should be detected."""
c = _make_creature(
name="Vindictive Lich", pt="4/1", cmc=4,
oracle_text="When Vindictive Lich dies, it deals 3 damage to target opponent.",
)
assert c.dies_damage == 3
assert c.dies_damage_target == "opponent"
def test_each_opponent_etb(self):
"""'each opponent' ETB damage correctly classified."""
c = _make_creature(
name="Siege-Gang Commander", pt="2/2", cmc=5,
oracle_text="When Siege-Gang Commander enters the battlefield, it deals 2 damage to each opponent.",
)
assert c.etb_damage == 2
assert c.etb_damage_target == "each_opponent"
def test_each_opponent_dies(self):
"""'each opponent' dies damage correctly classified."""
c = _make_creature(
name="Fiery Emancipation", pt="3/3", cmc=4,
oracle_text="When Fiery Emancipation dies, it deals 3 damage to each opponent.",
)
assert c.dies_damage == 3
assert c.dies_damage_target == "each_opponent"
def test_no_trigger_on_vanilla(self):
"""Vanilla creatures should have no trigger damage."""
c = _make_creature(name="Grizzly Bears", pt="2/2", cmc=2)
assert c.etb_damage == 0
assert c.dies_damage == 0
def test_any_target_detected(self):
"""'any target' ETB should be detected."""
c = _make_creature(
name="Burning Sun's Avatar", pt="6/6", cmc=6,
oracle_text="When Burning Sun's Avatar enters the battlefield, it deals 3 damage to any target.",
)
assert c.etb_damage == 3
assert c.etb_damage_target == "any_target"
# ══════════════════════════════════════════════════════════════
# ETB trigger firing tests
# ══════════════════════════════════════════════════════════════
class TestETBTriggerFiring:
def test_etb_deals_damage_to_opponent(self):
"""ETB creature with 'opponent' target damages weakest opponent."""
etb_creature = _make_creature(
name="Inferno Titan", pt="6/6", cmc=6,
oracle_text="When Inferno Titan enters the battlefield, it deals 3 damage to target opponent.",
)
engine, sim = _setup_two_player(
p0_hand=[etb_creature],
p0_battlefield=[_make_land() for _ in range(6)],
p1_life=20,
)
events = []
engine._fire_etb_trigger(sim, etb_creature, controller_seat=0, events=events)
assert sim.players[1].life == 17
assert sim.players[0].stats.damage_dealt == 3
assert any("etb trigger" in e for e in events)
def test_etb_each_opponent_hits_all(self):
"""'each_opponent' ETB in a 3-player game damages all opponents."""
etb_creature = _make_creature(
name="Purphoros", pt="5/5", cmc=4,
oracle_text="When Purphoros enters the battlefield, it deals 2 damage to each opponent.",
)
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
for i in range(3):
sim.players.append(Player(
name=f"P{i}", life=20, owner_id=i,
library=[_make_land() for _ in range(10)],
stats=PlayerStats(cards_drawn=7),
))
sim.init_battlefields(3)
events = []
engine._fire_etb_trigger(sim, etb_creature, controller_seat=0, events=events)
assert sim.players[0].life == 20 # controller unaffected
assert sim.players[1].life == 18
assert sim.players[2].life == 18
assert sim.players[0].stats.damage_dealt == 4 # 2 to each of 2 opponents
def test_etb_no_trigger_on_vanilla(self):
"""Vanilla creature ETB should not deal damage."""
bear = _make_creature(name="Bear", pt="2/2", cmc=2)
engine, sim = _setup_two_player(p1_life=20)
engine._fire_etb_trigger(sim, bear, controller_seat=0)
assert sim.players[1].life == 20
def test_etb_fires_during_spell_cast(self):
"""ETB trigger fires when creature is played via _play_spells."""
etb_creature = _make_creature(
name="Avalanche Riders", pt="2/2", cmc=4,
oracle_text="When Avalanche Riders enters the battlefield, it deals 2 damage to target opponent.",
)
engine, sim = _setup_two_player(
p0_hand=[etb_creature],
p0_battlefield=[_make_land() for _ in range(4)],
p1_life=20,
)
events = []
available_mana = 4
engine._play_spells(sim, 0, available_mana, events)
# Creature should have been cast and ETB should have fired
assert sim.players[1].life == 18
assert any("etb trigger" in e.lower() for e in events)
# ══════════════════════════════════════════════════════════════
# Dies trigger firing tests
# ══════════════════════════════════════════════════════════════
class TestDiesTriggerFiring:
def test_dies_trigger_on_removal(self):
"""Dies trigger fires when creature is killed by removal."""
dies_creature = _make_creature(
name="Vengeful Dead", pt="3/2", cmc=3,
oracle_text="When Vengeful Dead dies, it deals 2 damage to target opponent.",
)
removal = Card(name="Murder", type_line="Instant", cmc=3,
oracle_text="Destroy target creature.", is_removal=True)
enrich_card(removal)
engine, sim = _setup_two_player(
p0_hand=[removal],
p0_battlefield=[_make_land() for _ in range(3)],
p1_battlefield=[dies_creature],
p0_life=20,
)
events = []
engine._play_spells(sim, 0, 3, events)
# Murder kills the dies_creature; its dies trigger should deal 2 to P0
# (since P1 controls the dying creature, P1's trigger targets the weakest opponent = P0)
assert sim.players[0].life == 18 # dies trigger dealt 2 to P0
def test_dies_trigger_on_board_wipe(self):
"""Dies triggers fire for each creature destroyed by board wipe."""
dies_a = _make_creature(
name="Doomed Necro", pt="2/2", cmc=2,
oracle_text="When Doomed Necro dies, it deals 1 damage to each opponent.",
)
dies_b = _make_creature(
name="Spite Elemental", pt="3/1", cmc=3,
oracle_text="When Spite Elemental dies, it deals 1 damage to each opponent.",
)
wipe = Card(name="Wrath of God", type_line="Sorcery", cmc=4,
oracle_text="Destroy all creatures.", is_board_wipe=True)
enrich_card(wipe)
p0_bf = [_make_land() for _ in range(4)] + [dies_a]
engine, sim = _setup_two_player(
p0_hand=[wipe],
p0_battlefield=p0_bf,
p1_battlefield=[dies_b],
p0_life=20,
p1_life=20,
)
events = []
engine._play_spells(sim, 0, 4, events)
# dies_a (owned by P0) dies → deals 1 to each opponent → P1 takes 1
# dies_b (owned by P1) dies → deals 1 to each opponent → P0 takes 1
assert sim.players[0].life == 19 # took 1 from dies_b
assert sim.players[1].life == 19 # took 1 from dies_a
def test_dies_trigger_in_combat_lethal_block(self):
"""Dies trigger fires when creature is killed by a lethal blocker.
The 3/1 has power >= 3 so the attack heuristic will choose to attack.
The 2/2 blocker has toughness > power-of-attacker=3? No, 2 < 3. But
the blocker will still block to save life. The blocker deals 2 damage
to the 3/1 (toughness 1) → attacker dies. Its dies trigger fires.
"""
dies_creature = _make_creature(
name="Goblin Arsonist", pt="3/1", cmc=1,
oracle_text="When Goblin Arsonist dies, it deals 1 damage to target opponent.",
)
blocker = _make_creature(name="Grizzly Bears", pt="2/2", cmc=2)
engine, sim = _setup_two_player(
p0_battlefield=[dies_creature],
p1_battlefield=[blocker],
p0_life=20,
p1_life=20,
)
dies_creature.turn_played = -1
events = []
engine._resolve_combat(sim, 0, turn=1, events=events)
# 3/1 attacks (power >= 3), 2/2 blocks. Bear deals 2 damage to 3/1 (toughness 1) → dies.
# 3/1 deals 3 damage to bear (toughness 2) → bear also dies.
# Dies trigger on the 3/1: deals 1 damage to P1 (weakest opponent).
# Combat damage: 0 (both blocked and killed each other, no trample)
# The bear's death also triggers _send_to_graveyard but bear has no dies_damage.
assert sim.players[1].life <= 19 # got hit by dies trigger (1 damage)
class TestDiesTriggerEachOpponent:
def test_dies_each_opponent_3player(self):
"""'each_opponent' dies trigger hits all opponents in 3-player game."""
dies_creature = _make_creature(
name="Blood Artist", pt="0/1", cmc=2,
oracle_text="When Blood Artist dies, it deals 1 damage to each opponent.",
)
engine = GameEngine(max_turns=5)
sim = SimState(max_turns=5)
for i in range(3):
sim.players.append(Player(
name=f"P{i}", life=20, owner_id=i,
library=[_make_land() for _ in range(10)],
stats=PlayerStats(cards_drawn=7),
))
sim.init_battlefields(3)
dies_creature.owner_id = 0
dies_creature.id = sim.next_card_id
sim.next_card_id += 1
sim.add_to_battlefield(0, dies_creature)
events = []
# Simulate the creature dying
sim.remove_from_battlefield(dies_creature.id)
engine._send_to_graveyard(sim, dies_creature, 0, controller_seat=0, events=events)
assert sim.players[0].life == 20 # controller unaffected
assert sim.players[1].life == 19
assert sim.players[2].life == 19
# ══════════════════════════════════════════════════════════════
# Combat damage through deal_damage() tests
# ══════════════════════════════════════════════════════════════
class TestCombatDamageCentralized:
def test_combat_damage_routes_through_deal_damage(self):
"""Combat damage should update stats via deal_damage() path."""
attacker = _make_creature(name="Goblin", pt="3/1", cmc=1)
engine, sim = _setup_two_player(
p0_battlefield=[attacker],
p1_life=20,
)
attacker.turn_played = -1 # not summoning sick
engine._resolve_combat(sim, 0, turn=1)
# 3/1 attacks unblocked → 3 damage to P1
assert sim.players[1].life == 17
assert sim.players[0].stats.damage_dealt == 3
assert sim.players[1].stats.damage_received == 3
def test_combat_lethal_eliminates_via_deal_damage(self):
"""Lethal combat damage eliminates the opponent via deal_damage()."""
big = _make_creature(name="Big Boy", pt="10/10", cmc=5)
engine, sim = _setup_two_player(
p0_battlefield=[big],
p1_life=5,
)
big.turn_played = -1
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].eliminated is True
assert sim.players[1].life <= 0
# ══════════════════════════════════════════════════════════════
# Integration: full game with trigger creatures
# ══════════════════════════════════════════════════════════════
class TestTriggeredDamageIntegration:
def test_full_game_with_etb_creatures_completes(self):
"""A full game with ETB damage creatures should complete without error."""
def _deck_with_etb(seed_offset=0):
cards = []
for i in range(24):
cards.append(_make_land())
for i in range(10):
cards.append(_make_creature(
name=f"ETB Creature {i + seed_offset}",
pt="2/2", cmc=3,
oracle_text="When this creature enters the battlefield, it deals 1 damage to target opponent.",
))
for i in range(10):
cards.append(_make_creature(
name=f"Dies Creature {i + seed_offset}",
pt="1/1", cmc=2,
oracle_text="When this creature dies, it deals 1 damage to each opponent.",
))
for i in range(16):
cards.append(_make_creature(
name=f"Vanilla {i + seed_offset}", pt="3/3", cmc=3,
))
return cards
engine = GameEngine(max_turns=15)
result = engine.run(
deck_a=_deck_with_etb(0),
deck_b=_deck_with_etb(100),
name_a="ETB Player",
name_b="Dies Player",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_etb_can_cause_elimination(self):
"""ETB trigger dealing lethal damage should eliminate the opponent."""
lethal_etb = _make_creature(
name="Lethal ETB", pt="1/1", cmc=1,
oracle_text="When Lethal ETB enters the battlefield, it deals 5 damage to target opponent.",
)
engine, sim = _setup_two_player(
p0_hand=[lethal_etb],
p0_battlefield=[_make_land()],
p1_life=3,
)
events = []
engine._play_spells(sim, 0, 1, events)
assert sim.players[1].eliminated is True
assert sim.players[1].life == -2