Compare commits

...
Author SHA1 Message Date
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
4 changed files with 651 additions and 16 deletions

No files matched your search

+142 -15
View File
@@ -343,9 +343,21 @@ class GameEngine:
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
@staticmethod
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
"""Route card to command zone if it's a commander, else graveyard."""
def _send_to_graveyard(self, sim: SimState, card, owner_idx: int,
controller_seat: int | None = None,
events: list | None = None) -> None:
"""Route card to command zone if it's a commander, else graveyard.
If the dying card has a dies-trigger damage ability, fire it through
deal_damage() before the zone change.
"""
# Fire dies-trigger damage before the card leaves
if card.dies_damage and card.is_creature():
trigger_seat = controller_seat if controller_seat is not None else owner_idx
self._fire_trigger_damage(
sim, card, "dies", trigger_seat, events=events,
)
owner = sim.players[owner_idx]
if card.is_commander:
card.tapped = False
@@ -354,6 +366,111 @@ class GameEngine:
else:
owner.graveyard.append(card)
# ──────────────────────────────────────────────────────────
# Centralized Damage
# ──────────────────────────────────────────────────────────
@staticmethod
def deal_damage(
sim: SimState,
amount: int,
target_seat: int,
source_card: Card | None = None,
source_seat: int | None = None,
is_combat: bool = False,
events: list | None = None,
label: str = "",
) -> int:
"""Apply *amount* damage to a player, updating stats and checking elimination.
Returns the actual damage dealt (always *amount* in this simplified model;
future prevention/replacement effects will reduce it here).
Parameters
----------
sim : SimState
amount : int – damage to deal (<=0 is a no-op)
target_seat : int – seat index of the player receiving damage
source_card : Card | None – the card that is the source (for logging)
source_seat : int | None – seat index of the damage source's controller
is_combat : bool – whether this is combat damage (for future commander tracking)
events : list | None – optional event log
label : str – descriptive label for the log entry
"""
if amount <= 0:
return 0
target = sim.players[target_seat]
if target.eliminated:
return 0
target.life -= amount
target.stats.damage_received += amount
if source_seat is not None:
sim.players[source_seat].stats.damage_dealt += amount
if events is not None and label:
events.append(f"{label} ({target.name} now at {target.life} life)")
if target.life <= 0:
target.eliminated = True
return amount
# ──────────────────────────────────────────────────────────
# Triggered-Ability Damage
# ──────────────────────────────────────────────────────────
def _fire_trigger_damage(
self,
sim: SimState,
card: Card,
trigger_kind: str,
controller_seat: int,
events: list | None = None,
) -> None:
"""Resolve a simple ETB or dies damage trigger through deal_damage().
trigger_kind: "etb" | "dies"
"""
if trigger_kind == "etb":
amount = card.etb_damage
target_mode = card.etb_damage_target
elif trigger_kind == "dies":
amount = card.dies_damage
target_mode = card.dies_damage_target
else:
return
if amount <= 0:
return
if target_mode == "each_opponent":
for si, sp in enumerate(sim.players):
if si == controller_seat or sp.eliminated:
continue
label = f"{card.name} {trigger_kind} trigger deals {amount} to {sp.name}"
self.deal_damage(
sim, amount, si,
source_card=card, source_seat=controller_seat,
events=events, label=label,
)
else:
# "opponent" / "any_target" — target the weakest non-eliminated opponent
best = self._select_attack_target(sim, controller_seat)
if best != -1:
target_name = sim.players[best].name
label = f"{card.name} {trigger_kind} trigger deals {amount} to {target_name}"
self.deal_damage(
sim, amount, best,
source_card=card, source_seat=controller_seat,
events=events, label=label,
)
def _fire_etb_trigger(self, sim: SimState, card: Card, controller_seat: int,
events: list | None = None) -> None:
"""Fire an ETB damage trigger if the card has one."""
if card.etb_damage and card.is_creature():
self._fire_trigger_damage(sim, card, "etb", controller_seat, events=events)
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
"""Count untapped lands for a player."""
return sum(
@@ -414,7 +531,8 @@ class GameEngine:
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
killed = all_opp_creatures[0]
sim.remove_from_battlefield(killed.id)
self._send_to_graveyard(sim, killed, killed.owner_id)
self._send_to_graveyard(sim, killed, killed.owner_id,
controller_seat=killed.owner_id, events=events)
if events is not None:
events.append(f"Cast {card.name} (removal) \u2014 destroyed {killed.name}")
else:
@@ -430,7 +548,8 @@ class GameEngine:
for c in bf:
if c.is_creature():
wiped_names.append(c.name)
self._send_to_graveyard(sim, c, c.owner_id)
self._send_to_graveyard(sim, c, c.owner_id,
controller_seat=c.owner_id, events=events)
else:
keep.append(c)
sim.battlefields[seat_idx] = keep
@@ -443,6 +562,8 @@ class GameEngine:
p.stats.creatures_played += 1
if events is not None:
events.append(f"Cast {card.name} ({card.pt or 'creature'}) for {card.cmc} mana")
# Fire ETB damage trigger
self._fire_etb_trigger(sim, card, pi, events=events)
elif card.is_ramp:
p.stats.ramp_played += 1
if events is not None:
@@ -481,6 +602,9 @@ class GameEngine:
f"Cast commander {cmd_card.name} from command zone "
f"for {total_cost} mana (tax={tax})"
)
# Fire ETB damage trigger for commander creatures
if cmd_card.is_creature():
self._fire_etb_trigger(sim, cmd_card, pi, events=events)
available_mana = self._count_untapped_lands(sim, pi)
@staticmethod
@@ -595,15 +719,17 @@ class GameEngine:
blocker.damage_marked += a_pow
if blocker.damage_marked >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
sim.remove_from_battlefield(blocker.id)
self._send_to_graveyard(sim, blocker, opp_idx)
if events is not None:
combat_details.append(f" {blocker.name} dies")
self._send_to_graveyard(sim, blocker, opp_idx,
controller_seat=opp_idx, events=events)
atk.damage_marked += b_pow
if atk.damage_marked >= a_tou or blocker.has_keyword("deathtouch"):
sim.remove_from_battlefield(atk.id)
self._send_to_graveyard(sim, atk, pi)
if events is not None:
combat_details.append(f" {atk.name} dies")
self._send_to_graveyard(sim, atk, pi,
controller_seat=pi, events=events)
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
@@ -627,14 +753,15 @@ class GameEngine:
total_damage += a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
opp.life -= total_damage
p.stats.damage_dealt += total_damage
opp.stats.damage_received += total_damage
if events is not None and total_damage > 0:
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
events.extend(combat_details)
if opp.life <= 0:
opp.eliminated = True
if total_damage > 0:
self.deal_damage(
sim, total_damage, opp_idx,
source_seat=pi, is_combat=True,
events=events,
label=f"Dealt {total_damage} combat damage to {opp.name}",
)
if events is not None:
events.extend(combat_details)
def _build_result(
self,
+6
View File
@@ -38,6 +38,12 @@ class Card:
is_board_wipe: bool = False
is_commander: bool = False
# Triggered-ability damage flags (set by enrich_card)
etb_damage: int = 0 # damage dealt when this creature enters the battlefield
dies_damage: int = 0 # damage dealt when this creature dies
etb_damage_target: str = "" # "opponent" | "any_target" | "each_opponent"
dies_damage_target: str = "" # "opponent" | "any_target" | "each_opponent"
# Runtime fields (set during game)
id: int = 0
owner_id: int = -1
+55 -1
View File
@@ -186,7 +186,7 @@ def enrich_card(card: Card) -> Card:
def _apply_oracle_flags(card: Card) -> None:
"""Set is_removal, is_ramp, is_board_wipe from oracle_text and type_line."""
"""Set is_removal, is_ramp, is_board_wipe, and trigger-damage flags from oracle_text and type_line."""
oracle = (card.oracle_text or "").lower()
type_line = (card.type_line or "").lower()
@@ -223,6 +223,60 @@ def _apply_oracle_flags(card: Card) -> None:
):
card.is_ramp = True
# Triggered-ability damage detection (ETB / dies)
_detect_trigger_damage(card, oracle)
# ── Trigger-damage detection ─────────────────────────────────
# Patterns: "when ~ enters the battlefield, ~ deals N damage to ..."
# and "when ~ dies, ~ deals N damage to ..."
_ETB_DAMAGE_RE = re.compile(
r"when\b.*\benters\b.*\bdeals?\s+(\d+)\s+damage\b",
re.IGNORECASE,
)
_DIES_DAMAGE_RE = re.compile(
r"when\b.*\bdies\b.*\bdeals?\s+(\d+)\s+damage\b",
re.IGNORECASE,
)
def _detect_trigger_damage(card: Card, oracle: str) -> None:
"""Detect ETB and dies damage triggers from oracle text and set card flags."""
if not oracle:
return
# ETB damage
if not card.etb_damage:
m = _ETB_DAMAGE_RE.search(oracle)
if m:
card.etb_damage = int(m.group(1))
card.etb_damage_target = _classify_trigger_target(oracle, "enters")
# Dies damage
if not card.dies_damage:
m = _DIES_DAMAGE_RE.search(oracle)
if m:
card.dies_damage = int(m.group(1))
card.dies_damage_target = _classify_trigger_target(oracle, "dies")
def _classify_trigger_target(oracle: str, trigger_word: str) -> str:
"""Classify the target mode of a damage trigger from oracle text.
Returns "each_opponent", "any_target", or "opponent".
"""
# Find the clause containing the trigger word
# Look for "each opponent" or "each player" after the trigger
idx = oracle.find(trigger_word)
clause = oracle[idx:] if idx != -1 else oracle
if "each opponent" in clause or "each player" in clause:
return "each_opponent"
if "any target" in clause or "target creature or player" in clause or "target creature or planeswalker" in clause:
return "any_target"
return "opponent"
# ── Multi-opponent scaling factors ────────────────────────────
# Keywords / effects that become more valuable in larger pods.
+448
View File
@@ -0,0 +1,448 @@
"""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