Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5955473d7 |
No files matched your search
@@ -343,6 +343,53 @@ class GameEngine:
|
||||
sim.add_to_battlefield(pi, land_card)
|
||||
p.stats.lands_played += 1
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized Damage
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _apply_damage(
|
||||
sim: SimState,
|
||||
amount: int,
|
||||
target_seat: int,
|
||||
source_seat: int,
|
||||
*,
|
||||
source_is_commander: bool = False,
|
||||
is_combat: bool = False,
|
||||
) -> int:
|
||||
"""Apply damage to a player through a single centralized path.
|
||||
|
||||
Handles:
|
||||
- Life total reduction
|
||||
- Commander damage tracking (when source_is_commander=True)
|
||||
- Per-player damage stats
|
||||
- Elimination by life<=0 or commander damage>=21
|
||||
|
||||
Returns the amount of damage actually applied (always ``amount``
|
||||
unless the target is already eliminated, in which case 0).
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0
|
||||
target = sim.players[target_seat]
|
||||
if target.eliminated:
|
||||
return 0
|
||||
|
||||
target.life -= amount
|
||||
target.stats.damage_received += amount
|
||||
sim.players[source_seat].stats.damage_dealt += amount
|
||||
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
|
||||
"""Route card to command zone if it's a commander, else graveyard."""
|
||||
@@ -483,52 +530,6 @@ class GameEngine:
|
||||
)
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized damage routing
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _apply_damage(
|
||||
sim: SimState,
|
||||
source_card: Card | None,
|
||||
source_seat: int,
|
||||
target_player: Player,
|
||||
amount: int,
|
||||
) -> None:
|
||||
"""Route damage through a single path that handles commander tracking.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sim : SimState
|
||||
The current game state.
|
||||
source_card : Card | None
|
||||
The card that is the source of the damage (used for commander
|
||||
identification). May be ``None`` for non-card damage sources.
|
||||
source_seat : int
|
||||
Seat index of the player who controls the damage source.
|
||||
target_player : Player
|
||||
The player receiving the damage.
|
||||
amount : int
|
||||
Amount of damage to deal. Zero or negative is a no-op.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return
|
||||
|
||||
target_player.life -= amount
|
||||
target_player.stats.damage_received += amount
|
||||
|
||||
# Track commander damage (MTG rule 903.10a)
|
||||
if source_card is not None and source_card.is_commander:
|
||||
target_player.commander_damage_received[source_seat] = (
|
||||
target_player.commander_damage_received.get(source_seat, 0) + amount
|
||||
)
|
||||
|
||||
# Check lethal: life <= 0 or commander damage >= 21
|
||||
if target_player.life <= 0:
|
||||
target_player.eliminated = True
|
||||
elif any(v >= 21 for v in target_player.commander_damage_received.values()):
|
||||
target_player.eliminated = True
|
||||
|
||||
@staticmethod
|
||||
def _select_attack_target(sim: SimState, pi: int) -> int:
|
||||
"""Select the best opponent to attack.
|
||||
@@ -602,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()
|
||||
@@ -618,7 +623,6 @@ class GameEngine:
|
||||
)
|
||||
]
|
||||
blocked = False
|
||||
atk_player_damage = 0 # damage this attacker deals to the opponent player
|
||||
if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2):
|
||||
if atk.has_keyword("menace") and len(valid_blockers) >= 2:
|
||||
valid_blockers.sort(key=lambda b: -b.get_toughness())
|
||||
@@ -654,31 +658,49 @@ class GameEngine:
|
||||
trample_over = 0
|
||||
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
|
||||
trample_over = a_pow - blocker.get_toughness()
|
||||
atk_player_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
atk_player_damage += a_pow
|
||||
if atk.is_commander:
|
||||
commander_damage += trample_over
|
||||
else:
|
||||
atk_player_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"):
|
||||
atk_player_damage += a_pow * 2
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow * 2
|
||||
atk_dmg = a_pow * 2
|
||||
else:
|
||||
atk_player_damage += a_pow
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow
|
||||
# Route this attacker's damage through the centralized damage path
|
||||
if atk_player_damage > 0:
|
||||
self._apply_damage(sim, atk, pi, opp, atk_player_damage)
|
||||
p.stats.damage_dealt += atk_player_damage
|
||||
total_damage += atk_player_damage
|
||||
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 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)
|
||||
|
||||
@@ -95,17 +95,11 @@ class CommanderPlayer:
|
||||
# Whether this player has drawn their card this turn
|
||||
drawn_this_turn: bool = False
|
||||
|
||||
# Commander damage received from each opponent (keyed by seat index)
|
||||
commander_damage_received: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# ── Convenience pass-throughs to base Player ─────────────
|
||||
|
||||
@property
|
||||
def commander_damage_received(self) -> dict[int, int]:
|
||||
"""Delegate to base Player's commander_damage_received."""
|
||||
return self.base.commander_damage_received
|
||||
|
||||
@commander_damage_received.setter
|
||||
def commander_damage_received(self, value: dict[int, int]) -> None:
|
||||
self.base.commander_damage_received = value
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.base.name
|
||||
|
||||
@@ -153,8 +153,8 @@ class Player:
|
||||
commander_tax: dict = field(default_factory=dict)
|
||||
|
||||
# Commander damage received from each opponent seat.
|
||||
# Keyed by opponent seat index; value = cumulative commander damage.
|
||||
# A player loses when any single value reaches 21+ (MTG rule 903.10a).
|
||||
# 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)
|
||||
|
||||
@@ -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,342 +0,0 @@
|
||||
"""
|
||||
Regression tests for centralized damage routing (Issue #86).
|
||||
|
||||
Covers:
|
||||
- _apply_damage() source-aware routing
|
||||
- Commander damage accumulation via combat
|
||||
- Commander damage elimination at 21+
|
||||
- Regular (non-commander) combat damage does NOT track commander damage
|
||||
- Trample overflow from commanders tracks correctly
|
||||
- Double strike commander damage doubled correctly
|
||||
- Lifelink healing alongside commander damage tracking
|
||||
- Multi-attacker mixed commander + non-commander combat
|
||||
- Full game with commander damage kill
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _make_creature(
|
||||
name: str = "Bear",
|
||||
power: int = 2,
|
||||
toughness: int = 2,
|
||||
keywords: list[str] | None = None,
|
||||
is_commander: bool = False,
|
||||
card_id: int = 1,
|
||||
owner_id: int = 0,
|
||||
turn_played: int = -1,
|
||||
) -> Card:
|
||||
"""Create a creature card for testing."""
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
pt=f"{power}/{toughness}",
|
||||
power=str(power),
|
||||
toughness=str(toughness),
|
||||
keywords=keywords or [],
|
||||
is_commander=is_commander,
|
||||
id=card_id,
|
||||
owner_id=owner_id,
|
||||
turn_played=turn_played,
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
def _two_player_sim(
|
||||
p0_life: int = 40,
|
||||
p1_life: int = 40,
|
||||
p0_battlefield: list[Card] | None = None,
|
||||
p1_battlefield: list[Card] | None = None,
|
||||
) -> SimState:
|
||||
"""Create a minimal 2-player SimState for damage tests."""
|
||||
sim = SimState()
|
||||
sim.init_battlefields(2)
|
||||
sim.players.append(Player(name="Attacker", life=p0_life, owner_id=0, stats=PlayerStats()))
|
||||
sim.players.append(Player(name="Defender", life=p1_life, owner_id=1, stats=PlayerStats()))
|
||||
for c in (p0_battlefield or []):
|
||||
sim.add_to_battlefield(0, c)
|
||||
for c in (p1_battlefield or []):
|
||||
sim.add_to_battlefield(1, c)
|
||||
return sim
|
||||
|
||||
|
||||
# ── _apply_damage unit tests ─────────────────────────────────
|
||||
|
||||
class TestApplyDamage:
|
||||
"""Direct tests of the _apply_damage static method."""
|
||||
|
||||
def test_regular_creature_damage_reduces_life(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", power=2, toughness=2, is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 5)
|
||||
assert target.life == 35
|
||||
assert target.stats.damage_received == 5
|
||||
|
||||
def test_regular_creature_no_commander_damage_tracked(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 10)
|
||||
assert target.commander_damage_received == {}
|
||||
|
||||
def test_commander_damage_tracked(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=4, toughness=4, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 4)
|
||||
assert target.commander_damage_received == {0: 4}
|
||||
assert target.life == 36
|
||||
|
||||
def test_commander_damage_accumulates(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=4, toughness=4, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 10)
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 10)
|
||||
assert target.commander_damage_received == {0: 20}
|
||||
assert target.life == 20
|
||||
assert not target.eliminated # 20 < 21
|
||||
|
||||
def test_commander_damage_21_eliminates(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=4, toughness=4, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 21)
|
||||
assert target.eliminated
|
||||
assert target.commander_damage_received == {0: 21}
|
||||
assert target.life == 19 # still above 0 but eliminated by cmdr damage
|
||||
|
||||
def test_commander_damage_over_21_eliminates(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=7, toughness=7, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 10)
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 12)
|
||||
assert target.eliminated
|
||||
assert target.commander_damage_received == {0: 22}
|
||||
|
||||
def test_life_lethal_eliminates(self):
|
||||
sim = _two_player_sim(p1_life=5)
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 5)
|
||||
assert target.eliminated
|
||||
assert target.life == 0
|
||||
|
||||
def test_zero_damage_is_noop(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 0)
|
||||
assert target.life == 40
|
||||
assert target.stats.damage_received == 0
|
||||
|
||||
def test_negative_damage_is_noop(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, -3)
|
||||
assert target.life == 40
|
||||
|
||||
def test_none_source_card_no_commander_tracking(self):
|
||||
"""Damage with no source card should not track commander damage."""
|
||||
sim = _two_player_sim()
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, None, 0, target, 5)
|
||||
assert target.life == 35
|
||||
assert target.commander_damage_received == {}
|
||||
|
||||
def test_multiple_commanders_tracked_separately(self):
|
||||
"""Commander damage from different seats tracked independently."""
|
||||
sim = SimState()
|
||||
sim.init_battlefields(3)
|
||||
for i in range(3):
|
||||
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i, stats=PlayerStats()))
|
||||
target = sim.players[2]
|
||||
cmd_a = _make_creature("Atraxa", is_commander=True)
|
||||
cmd_b = _make_creature("Korvold", is_commander=True)
|
||||
GameEngine._apply_damage(sim, cmd_a, 0, target, 15)
|
||||
GameEngine._apply_damage(sim, cmd_b, 1, target, 10)
|
||||
assert target.commander_damage_received == {0: 15, 1: 10}
|
||||
assert target.life == 15
|
||||
assert not target.eliminated # neither source at 21
|
||||
|
||||
def test_one_of_multiple_commanders_reaches_21(self):
|
||||
"""Elimination triggers when one opponent's commander damage hits 21."""
|
||||
sim = SimState()
|
||||
sim.init_battlefields(3)
|
||||
for i in range(3):
|
||||
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i, stats=PlayerStats()))
|
||||
target = sim.players[2]
|
||||
cmd_a = _make_creature("Atraxa", is_commander=True)
|
||||
cmd_b = _make_creature("Korvold", is_commander=True)
|
||||
GameEngine._apply_damage(sim, cmd_a, 0, target, 21)
|
||||
assert target.eliminated
|
||||
# Second commander's damage is still at 0
|
||||
assert target.commander_damage_received.get(1, 0) == 0
|
||||
|
||||
|
||||
# ── Combat integration tests ────────────────────────────────
|
||||
|
||||
class TestCombatCommanderDamage:
|
||||
"""Integration tests: commander damage tracking through _resolve_combat."""
|
||||
|
||||
def _run_combat(
|
||||
self,
|
||||
attackers: list[Card],
|
||||
blockers: list[Card] | None = None,
|
||||
p0_life: int = 40,
|
||||
p1_life: int = 40,
|
||||
) -> tuple[SimState, GameEngine]:
|
||||
"""Set up and run a single combat step."""
|
||||
sim = _two_player_sim(
|
||||
p0_life=p0_life,
|
||||
p1_life=p1_life,
|
||||
p0_battlefield=attackers,
|
||||
p1_battlefield=blockers or [],
|
||||
)
|
||||
sim.turn = 1 # attackers need turn_played < sim.turn
|
||||
engine = GameEngine()
|
||||
engine._resolve_combat(sim, pi=0, turn=1)
|
||||
return sim, engine
|
||||
|
||||
def test_unblocked_commander_tracks_damage(self):
|
||||
cmd = _make_creature("Atraxa", power=4, toughness=4, is_commander=True, card_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 36
|
||||
assert defender.commander_damage_received == {0: 4}
|
||||
|
||||
def test_unblocked_regular_creature_no_commander_damage(self):
|
||||
bear = _make_creature("Bear", power=3, toughness=3, is_commander=False, card_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([bear])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 37
|
||||
assert defender.commander_damage_received == {}
|
||||
|
||||
def test_mixed_attackers_only_commander_tracked(self):
|
||||
"""When commander + regular creature attack, only commander damage is tracked."""
|
||||
cmd = _make_creature("Atraxa", power=4, toughness=4, is_commander=True, card_id=1, turn_played=0)
|
||||
bear = _make_creature("Bear", power=3, toughness=3, is_commander=False, card_id=2, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd, bear])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 33 # 40 - 4 - 3
|
||||
assert defender.commander_damage_received == {0: 4}
|
||||
|
||||
def test_commander_trample_overflow_tracked(self):
|
||||
"""Trample overflow from commander attack should be tracked as commander damage.
|
||||
|
||||
The AI blocking heuristic chump-blocks when opp.life <= a_pow * 2.
|
||||
With life=10 and attacker power=6 (10 <= 12), the 0/3 will chump block.
|
||||
Trample overflow = 6 - 3 = 3, tracked as commander damage.
|
||||
"""
|
||||
cmd = _make_creature(
|
||||
"Atraxa", power=6, toughness=6, is_commander=True,
|
||||
keywords=["trample"], card_id=1, turn_played=0,
|
||||
)
|
||||
blocker = _make_creature("Wall", power=0, toughness=3, card_id=10, owner_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd], [blocker], p1_life=10)
|
||||
defender = sim.players[1]
|
||||
trample_over = 6 - 3 # power - blocker toughness
|
||||
assert defender.commander_damage_received == {0: trample_over}
|
||||
assert defender.life == 10 - trample_over
|
||||
|
||||
def test_commander_double_strike_unblocked(self):
|
||||
"""Double strike commander deals 2x damage, all tracked as commander damage."""
|
||||
cmd = _make_creature(
|
||||
"Rafiq", power=3, toughness=3, is_commander=True,
|
||||
keywords=["double strike"], card_id=1, turn_played=0,
|
||||
)
|
||||
sim, _ = self._run_combat([cmd])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 34 # 40 - 3*2
|
||||
assert defender.commander_damage_received == {0: 6}
|
||||
|
||||
def test_commander_lifelink_heals_attacker(self):
|
||||
"""Lifelink on commander heals attacker while tracking commander damage."""
|
||||
cmd = _make_creature(
|
||||
"Oloro", power=4, toughness=4, is_commander=True,
|
||||
keywords=["lifelink"], card_id=1, turn_played=0,
|
||||
)
|
||||
sim, _ = self._run_combat([cmd], p0_life=30)
|
||||
attacker = sim.players[0]
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 36
|
||||
assert defender.commander_damage_received == {0: 4}
|
||||
assert attacker.life == 34 # healed 4
|
||||
|
||||
def test_blocked_commander_no_player_damage(self):
|
||||
"""Fully blocked commander with no trample deals no player damage."""
|
||||
cmd = _make_creature(
|
||||
"Atraxa", power=3, toughness=3, is_commander=True,
|
||||
card_id=1, turn_played=0,
|
||||
)
|
||||
blocker = _make_creature("Wall", power=0, toughness=5, card_id=10, owner_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd], [blocker])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 40
|
||||
assert defender.commander_damage_received == {}
|
||||
|
||||
def test_cumulative_combat_commander_damage_kills(self):
|
||||
"""Multiple combat steps accumulate commander damage to 21+."""
|
||||
sim = _two_player_sim(
|
||||
p0_battlefield=[
|
||||
_make_creature("Atraxa", power=7, toughness=7, is_commander=True, card_id=1, turn_played=0)
|
||||
],
|
||||
)
|
||||
sim.turn = 1
|
||||
engine = GameEngine()
|
||||
# Swing 3 times (7 * 3 = 21)
|
||||
for _ in range(3):
|
||||
# Untap attacker between swings
|
||||
for c in sim.get_battlefield(0):
|
||||
c.tapped = False
|
||||
engine._resolve_combat(sim, pi=0, turn=1)
|
||||
defender = sim.players[1]
|
||||
assert defender.commander_damage_received == {0: 21}
|
||||
assert defender.eliminated
|
||||
|
||||
def test_stats_damage_dealt_tracked(self):
|
||||
"""Attacker's damage_dealt stat is correctly updated."""
|
||||
cmd = _make_creature("Atraxa", power=5, toughness=5, is_commander=True, card_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd])
|
||||
attacker = sim.players[0]
|
||||
assert attacker.stats.damage_dealt == 5
|
||||
|
||||
|
||||
# ── CommanderGameState compat tests ──────────────────────────
|
||||
|
||||
class TestGameStateCompat:
|
||||
"""Ensure CommanderGameState still works after Player model change."""
|
||||
|
||||
def test_commander_player_delegates_to_base(self):
|
||||
from commander_ai_lab.sim.game_state import CommanderPlayer
|
||||
p = Player(name="Test", life=40)
|
||||
cp = CommanderPlayer(base=p)
|
||||
cp.commander_damage_received[0] = 15
|
||||
assert p.commander_damage_received == {0: 15}
|
||||
|
||||
def test_commander_game_state_deal_damage(self):
|
||||
from commander_ai_lab.sim.game_state import CommanderGameState
|
||||
sim = SimState()
|
||||
sim.init_battlefields(2)
|
||||
sim.players.append(Player(name="P0", life=40, owner_id=0))
|
||||
sim.players.append(Player(name="P1", life=40, owner_id=1))
|
||||
gs = CommanderGameState.from_sim_state(sim)
|
||||
gs.deal_commander_damage(from_seat=0, to_seat=1, amount=10)
|
||||
assert gs.get_commander_damage(0, 1) == 10
|
||||
assert gs.commander_players[1].life == 30
|
||||
# Base Player also has the damage
|
||||
assert sim.players[1].commander_damage_received == {0: 10}
|
||||
|
||||
def test_is_dead_to_commander_damage_via_base(self):
|
||||
from commander_ai_lab.sim.game_state import CommanderPlayer
|
||||
p = Player(name="Test", life=40)
|
||||
cp = CommanderPlayer(base=p)
|
||||
p.commander_damage_received[0] = 21
|
||||
assert cp.is_dead_to_commander_damage()
|
||||
Reference in new issue
Block a user