Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5955473d7 |
No files matched your search
@@ -343,59 +343,30 @@ class GameEngine:
|
||||
sim.add_to_battlefield(pi, land_card)
|
||||
p.stats.lands_played += 1
|
||||
|
||||
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
|
||||
card.turn_played = -1
|
||||
owner.command_zone.append(card)
|
||||
else:
|
||||
owner.graveyard.append(card)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized Damage
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def deal_damage(
|
||||
def _apply_damage(
|
||||
sim: SimState,
|
||||
amount: int,
|
||||
target_seat: int,
|
||||
source_card: Card | None = None,
|
||||
source_seat: int | None = None,
|
||||
source_seat: int,
|
||||
*,
|
||||
source_is_commander: bool = False,
|
||||
is_combat: bool = False,
|
||||
events: list | None = None,
|
||||
label: str = "",
|
||||
) -> int:
|
||||
"""Apply *amount* damage to a player, updating stats and checking elimination.
|
||||
"""Apply damage to a player through a single centralized path.
|
||||
|
||||
Returns the actual damage dealt (always *amount* in this simplified model;
|
||||
future prevention/replacement effects will reduce it here).
|
||||
Handles:
|
||||
- Life total reduction
|
||||
- Commander damage tracking (when source_is_commander=True)
|
||||
- Per-player damage stats
|
||||
- Elimination by life<=0 or commander damage>=21
|
||||
|
||||
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
|
||||
Returns the amount of damage actually applied (always ``amount``
|
||||
unless the target is already eliminated, in which case 0).
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0
|
||||
@@ -405,71 +376,30 @@ class GameEngine:
|
||||
|
||||
target.life -= amount
|
||||
target.stats.damage_received += amount
|
||||
if source_seat is not None:
|
||||
sim.players[source_seat].stats.damage_dealt += amount
|
||||
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 source_is_commander:
|
||||
prev = target.commander_damage_received.get(source_seat, 0)
|
||||
target.commander_damage_received[source_seat] = prev + amount
|
||||
|
||||
# Elimination checks: life<=0 OR 21+ commander damage from one source
|
||||
if target.life <= 0:
|
||||
target.eliminated = True
|
||||
elif any(v >= 21 for v in target.commander_damage_received.values()):
|
||||
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
|
||||
@staticmethod
|
||||
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
|
||||
"""Route card to command zone if it's a commander, else graveyard."""
|
||||
owner = sim.players[owner_idx]
|
||||
if card.is_commander:
|
||||
card.tapped = False
|
||||
card.turn_played = -1
|
||||
owner.command_zone.append(card)
|
||||
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)
|
||||
owner.graveyard.append(card)
|
||||
|
||||
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
|
||||
"""Count untapped lands for a player."""
|
||||
@@ -531,8 +461,7 @@ 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,
|
||||
controller_seat=killed.owner_id, events=events)
|
||||
self._send_to_graveyard(sim, killed, killed.owner_id)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal) \u2014 destroyed {killed.name}")
|
||||
else:
|
||||
@@ -548,8 +477,7 @@ class GameEngine:
|
||||
for c in bf:
|
||||
if c.is_creature():
|
||||
wiped_names.append(c.name)
|
||||
self._send_to_graveyard(sim, c, c.owner_id,
|
||||
controller_seat=c.owner_id, events=events)
|
||||
self._send_to_graveyard(sim, c, c.owner_id)
|
||||
else:
|
||||
keep.append(c)
|
||||
sim.battlefields[seat_idx] = keep
|
||||
@@ -562,8 +490,6 @@ 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:
|
||||
@@ -602,9 +528,6 @@ 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
|
||||
@@ -680,9 +603,13 @@ class GameEngine:
|
||||
if events is not None and attackers:
|
||||
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
|
||||
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
|
||||
total_damage = 0
|
||||
# Accumulate per-attacker player-damage buckets so we can route
|
||||
# commander vs non-commander damage through _apply_damage separately.
|
||||
commander_damage = 0 # damage dealt by commander creatures
|
||||
normal_damage = 0 # damage dealt by non-commander creatures
|
||||
used_blockers: set[int] = set()
|
||||
combat_details: list[str] = []
|
||||
lifelink_total = 0 # lifelink healing for the attacker
|
||||
for atk in attackers:
|
||||
a_pow = atk.get_power()
|
||||
a_tou = atk.get_toughness()
|
||||
@@ -719,49 +646,64 @@ 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()
|
||||
total_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
total_damage += a_pow
|
||||
if atk.is_commander:
|
||||
commander_damage += trample_over
|
||||
else:
|
||||
total_damage += trample_over
|
||||
normal_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
|
||||
if atk.is_commander:
|
||||
commander_damage += ds_dmg
|
||||
else:
|
||||
normal_damage += ds_dmg
|
||||
if atk.has_keyword("lifelink"):
|
||||
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
damage_dealt *= 2
|
||||
p.life += damage_dealt
|
||||
lifelink_total += damage_dealt
|
||||
if not blocked:
|
||||
if atk.has_keyword("double strike"):
|
||||
total_damage += a_pow * 2
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow * 2
|
||||
atk_dmg = a_pow * 2
|
||||
else:
|
||||
total_damage += a_pow
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow
|
||||
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}",
|
||||
atk_dmg = a_pow
|
||||
if atk.is_commander:
|
||||
commander_damage += atk_dmg
|
||||
else:
|
||||
normal_damage += atk_dmg
|
||||
if atk.has_keyword("lifelink"):
|
||||
lifelink_total += atk_dmg
|
||||
|
||||
# Route all player damage through the centralized path.
|
||||
# Commander damage is applied first (tracks 21-damage rule).
|
||||
total_damage = 0
|
||||
if commander_damage > 0:
|
||||
total_damage += self._apply_damage(
|
||||
sim, commander_damage, opp_idx, pi,
|
||||
source_is_commander=True, is_combat=True,
|
||||
)
|
||||
if events is not None:
|
||||
events.extend(combat_details)
|
||||
if normal_damage > 0:
|
||||
total_damage += self._apply_damage(
|
||||
sim, normal_damage, opp_idx, pi,
|
||||
source_is_commander=False, is_combat=True,
|
||||
)
|
||||
# Apply lifelink healing
|
||||
if lifelink_total > 0:
|
||||
p.life += lifelink_total
|
||||
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)
|
||||
|
||||
def _build_result(
|
||||
self,
|
||||
|
||||
@@ -38,12 +38,6 @@ 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
|
||||
@@ -158,6 +152,11 @@ class Player:
|
||||
# p.commander_tax[name] = tax + 2
|
||||
commander_tax: dict = field(default_factory=dict)
|
||||
|
||||
# Commander damage received from each opponent seat.
|
||||
# Keyed by opponent seat index; value is cumulative damage.
|
||||
# A player is eliminated when any single value reaches 21.
|
||||
commander_damage_received: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
stats: PlayerStats = field(default_factory=PlayerStats)
|
||||
|
||||
|
||||
|
||||
@@ -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, and trigger-damage flags from oracle_text and type_line."""
|
||||
"""Set is_removal, is_ramp, is_board_wipe from oracle_text and type_line."""
|
||||
oracle = (card.oracle_text or "").lower()
|
||||
type_line = (card.type_line or "").lower()
|
||||
|
||||
@@ -223,60 +223,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Regression tests for centralized commander damage tracking.
|
||||
============================================================
|
||||
Covers:
|
||||
- _apply_damage centralized path (life, stats, commander tracking, elimination)
|
||||
- Combat damage routing through _apply_damage (commander vs non-commander)
|
||||
- Commander damage 21-lethal elimination rule
|
||||
- Non-combat commander damage (direct _apply_damage calls)
|
||||
- Multi-seat / partner commander edge cases
|
||||
|
||||
Run with: pytest tests/sim/test_commander_damage.py -v
|
||||
"""
|
||||
|
||||
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_sim(num_players: int = 2, starting_life: int = 40) -> SimState:
|
||||
"""Create a minimal SimState with empty battlefields."""
|
||||
sim = SimState(max_turns=25)
|
||||
for i in range(num_players):
|
||||
sim.players.append(Player(
|
||||
name=f"P{i}",
|
||||
life=starting_life,
|
||||
owner_id=i,
|
||||
stats=PlayerStats(cards_drawn=0),
|
||||
))
|
||||
sim.init_battlefields(num_players)
|
||||
return sim
|
||||
|
||||
|
||||
def _creature(name: str, power: int, toughness: int, **kwargs) -> Card:
|
||||
"""Create a creature card with given stats."""
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
pt=f"{power}/{toughness}",
|
||||
power=str(power),
|
||||
toughness=str(toughness),
|
||||
**kwargs,
|
||||
)
|
||||
return enrich_card(c)
|
||||
|
||||
|
||||
def _land(name: str = "Forest") -> Card:
|
||||
c = Card(name=name, type_line="Basic Land — Forest", cmc=0)
|
||||
return enrich_card(c)
|
||||
|
||||
|
||||
def _place_on_battlefield(sim: SimState, seat: int, card: Card) -> Card:
|
||||
"""Place a card onto a seat's battlefield with a unique id."""
|
||||
card.owner_id = seat
|
||||
card.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
card.turn_played = -1 # played in a previous turn (not summoning sick)
|
||||
sim.add_to_battlefield(seat, card)
|
||||
return card
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _apply_damage unit tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestApplyDamage:
|
||||
"""Tests for the centralized _apply_damage static method."""
|
||||
|
||||
def test_basic_damage_reduces_life(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert applied == 5
|
||||
assert sim.players[1].life == 35
|
||||
assert sim.players[1].stats.damage_received == 5
|
||||
assert sim.players[0].stats.damage_dealt == 5
|
||||
|
||||
def test_zero_damage_is_noop(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, 0, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40
|
||||
|
||||
def test_negative_damage_is_noop(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, -3, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40
|
||||
|
||||
def test_damage_to_eliminated_player_is_noop(self):
|
||||
sim = _make_sim()
|
||||
sim.players[1].eliminated = True
|
||||
applied = GameEngine._apply_damage(sim, 10, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40 # unchanged
|
||||
|
||||
def test_lethal_damage_eliminates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 40, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == 0
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_overkill_damage_eliminates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 50, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == -10
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_non_commander_damage_does_not_track_commander_damage(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=1, source_seat=0,
|
||||
source_is_commander=False,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
|
||||
def test_commander_damage_tracks_per_seat(self):
|
||||
sim = _make_sim(num_players=3)
|
||||
GameEngine._apply_damage(
|
||||
sim, 7, target_seat=2, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 5, target_seat=2, source_seat=1,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[2].commander_damage_received == {0: 7, 1: 5}
|
||||
assert sim.players[2].life == 40 - 7 - 5
|
||||
|
||||
def test_commander_damage_accumulates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 8, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 18
|
||||
|
||||
def test_21_commander_damage_eliminates(self):
|
||||
"""Commander damage rule: 21+ from a single source eliminates."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100 # high life — shouldn't save them
|
||||
GameEngine._apply_damage(
|
||||
sim, 21, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].life == 79 # life was reduced but still positive
|
||||
|
||||
def test_incremental_commander_damage_to_21_eliminates(self):
|
||||
"""Gradual commander damage reaching 21 triggers elimination."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100
|
||||
for _ in range(3):
|
||||
GameEngine._apply_damage(
|
||||
sim, 7, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_split_commander_damage_from_two_sources_no_elimination(self):
|
||||
"""20 commander damage from two different sources should NOT eliminate."""
|
||||
sim = _make_sim(num_players=3)
|
||||
sim.players[2].life = 100
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=2, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=2, source_seat=1,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[2].commander_damage_received == {0: 10, 1: 10}
|
||||
assert sim.players[2].eliminated is False
|
||||
|
||||
def test_stats_update_on_both_players(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 12, target_seat=1, source_seat=0)
|
||||
assert sim.players[0].stats.damage_dealt == 12
|
||||
assert sim.players[1].stats.damage_received == 12
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Combat damage routing through _apply_damage
|
||||
# ===========================================================================
|
||||
|
||||
class TestCombatCommanderDamageRouting:
|
||||
"""Verify _resolve_combat routes commander creature damage correctly."""
|
||||
|
||||
def _setup_combat(self, *, commander_power=5, defender_life=40):
|
||||
"""Set up a 2-player sim with one commander attacker and no blockers."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2, starting_life=defender_life)
|
||||
# Attacker: a commander creature
|
||||
cmd = _creature("Prossh", commander_power, 5, is_commander=True)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
# Defender: only lands (no blockers)
|
||||
for _ in range(3):
|
||||
_place_on_battlefield(sim, 1, _land())
|
||||
return engine, sim
|
||||
|
||||
def test_unblocked_commander_tracks_damage(self):
|
||||
engine, sim = self._setup_combat(commander_power=5)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 5
|
||||
assert sim.players[1].life == 35
|
||||
|
||||
def test_unblocked_noncommander_does_not_track(self):
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
# Non-commander attacker
|
||||
bear = _creature("Grizzly Bears", 2, 2)
|
||||
_place_on_battlefield(sim, 0, bear)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
assert sim.players[1].life == 38
|
||||
|
||||
def test_commander_combat_21_eliminates(self):
|
||||
"""Commander dealing 21+ combat damage in one hit should eliminate."""
|
||||
engine, sim = self._setup_combat(commander_power=21, defender_life=100)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
|
||||
def test_mixed_commander_and_noncommander_attackers(self):
|
||||
"""Commander + non-commander attack together; only commander damage tracked."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
cmd = _creature("Prossh", 3, 3, is_commander=True)
|
||||
bear = _creature("Grizzly Bears", 2, 2)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
_place_on_battlefield(sim, 0, bear)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
# Total damage: 3 + 2 = 5
|
||||
assert sim.players[1].life == 35
|
||||
# Only commander damage tracked
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 3
|
||||
|
||||
def test_commander_trample_over_blocker_tracks_damage(self):
|
||||
"""Trample damage from a commander going over a blocker should track."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
# Low life forces the AI to assign a blocker via the
|
||||
# "opp.life <= a_pow * 2" heuristic even when toughness < power.
|
||||
sim = _make_sim(num_players=2, starting_life=10)
|
||||
cmd = _creature("Prossh", 7, 7, is_commander=True, keywords=["trample"])
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
blocker = _creature("Wall", 0, 3)
|
||||
_place_on_battlefield(sim, 1, blocker)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
# Trample over: 7 - 3 = 4 damage to player (all from commander)
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 4
|
||||
assert sim.players[1].life == 6
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Non-combat commander damage (direct _apply_damage usage)
|
||||
# ===========================================================================
|
||||
|
||||
class TestNonCombatCommanderDamage:
|
||||
"""
|
||||
Validates that _apply_damage correctly handles non-combat commander damage
|
||||
scenarios. These represent cases like commander abilities, ETB triggers,
|
||||
or spell effects that deal damage where the source is a commander.
|
||||
"""
|
||||
|
||||
def test_direct_commander_ability_damage(self):
|
||||
"""Simulates a commander dealing direct damage (e.g. Purphoros trigger)."""
|
||||
sim = _make_sim(num_players=4)
|
||||
# Commander at seat 0 deals 2 to each opponent via ability
|
||||
for target in [1, 2, 3]:
|
||||
GameEngine._apply_damage(
|
||||
sim, 2, target_seat=target, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
for target in [1, 2, 3]:
|
||||
assert sim.players[target].commander_damage_received[0] == 2
|
||||
assert sim.players[target].life == 38
|
||||
|
||||
def test_repeated_noncombat_commander_damage_reaches_21(self):
|
||||
"""Repeated non-combat commander triggers accumulate to lethal 21."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100
|
||||
# 11 triggers of 2 damage each = 22 total
|
||||
for _ in range(11):
|
||||
GameEngine._apply_damage(
|
||||
sim, 2, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 22
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_noncombat_noncommander_spell_damage(self):
|
||||
"""Non-commander spell damage: reduces life, no commander tracking."""
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 3, target_seat=1, source_seat=0,
|
||||
source_is_commander=False,
|
||||
)
|
||||
assert sim.players[1].life == 37
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
|
||||
def test_mixed_combat_and_noncombat_commander_damage(self):
|
||||
"""Both combat and non-combat commander damage should accumulate together."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
sim.players[1].life = 100
|
||||
|
||||
# Non-combat commander damage: 15
|
||||
GameEngine._apply_damage(
|
||||
sim, 15, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 15
|
||||
assert sim.players[1].eliminated is False
|
||||
|
||||
# Combat commander damage: 6 (pushes to 21)
|
||||
cmd = _creature("Prossh", 6, 6, is_commander=True)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Full game integration
|
||||
# ===========================================================================
|
||||
|
||||
class TestFullGameCommanderDamage:
|
||||
"""Integration test: run a full game with commanders and verify tracking."""
|
||||
|
||||
def _make_commander_deck(self, commander_name: str, seed: int = 42) -> list[Card]:
|
||||
"""Build a small test deck with a named commander."""
|
||||
import random
|
||||
rng = random.Random(seed)
|
||||
cards = []
|
||||
# Commander card
|
||||
cmd = Card(
|
||||
name=commander_name,
|
||||
type_line="Legendary Creature",
|
||||
cmc=5,
|
||||
pt="5/5",
|
||||
power="5",
|
||||
toughness="5",
|
||||
)
|
||||
cards.append(enrich_card(cmd))
|
||||
# Fill with lands and creatures
|
||||
for i in range(24):
|
||||
cards.append(enrich_card(Card(
|
||||
name=f"Forest_{i}",
|
||||
type_line="Basic Land — Forest",
|
||||
cmc=0,
|
||||
)))
|
||||
for i in range(35):
|
||||
cards.append(enrich_card(Card(
|
||||
name=f"Bear_{i}",
|
||||
type_line="Creature — Bear",
|
||||
cmc=2,
|
||||
pt="2/2",
|
||||
power="2",
|
||||
toughness="2",
|
||||
)))
|
||||
rng.shuffle(cards)
|
||||
return cards
|
||||
|
||||
def test_commander_damage_tracked_in_full_game(self):
|
||||
"""In a full game with commanders, commander_damage_received is populated."""
|
||||
engine = GameEngine(max_turns=15)
|
||||
deck_a = self._make_commander_deck("Prossh, Skyraider of Kher", seed=1)
|
||||
deck_b = self._make_commander_deck("Kaalia of the Vast", seed=2)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
commander_names=["Prossh, Skyraider of Kher", "Kaalia of the Vast"],
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
# The game completed without errors — commander damage tracking is wired
|
||||
@@ -1,448 +0,0 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user