Files
commander-ai-lab/tests/test_commander_damage.py
KoalaTrapLordandClaude e265b1e15b feat: commander damage source identity and per-card tracking (#86) (#89)
Track commander damage at combat-damage time by checking `is_commander`
on each attacking creature.  Damage from commander creatures is now
recorded in both a per-seat aggregate (`commander_damage_received`) and
a per-card breakdown (`commander_damage_by_card`), enabling correct
21-damage lethal checks for partner/multi-commander games.

Changes:
- engine._resolve_combat: identify commander attackers, accumulate their
  damage (including trample overflow and double strike), write to both
  tracking dicts, and check commander-damage elimination after combat.
- models.Player: add commander_damage_received, commander_damage_by_card,
  and is_dead_to_commander_damage() so the headless engine can track
  commander damage without the full CommanderGameState wrapper.
- game_state.CommanderPlayer: replace the separate
  commander_damage_received field with a property delegating to
  base.Player to avoid split-brain state between engine and wrapper.
- game_state.CommanderGameState.deal_commander_damage: accept optional
  commander_name for per-card tracking.
- 22 new regression tests covering unblocked, blocked, trample, double
  strike, lifelink, partner, cumulative, and lethal scenarios.

Partial fix for Issue #86 item 2.

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

411 lines
16 KiB
Python

"""
Commander Damage Regression Tests
==================================
Run with: pytest tests/test_commander_damage.py -v
Covers:
- Commander source identity during combat
- Trample overflow counts as commander damage
- Double strike commander damage
- Partner / multi-commander per-card tracking
- 21-damage lethal check (per-card, not per-seat)
- Commander damage through CommanderGameState helper
- Non-commander creatures do NOT generate commander damage
- Lifelink + commander damage interaction
"""
from __future__ import annotations
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.game_state import (
CommanderGameState,
CommanderPlayer,
)
# ── Helpers ──────────────────────────────────────────────────
def _make_creature(
name: str,
power: str = "3",
toughness: str = "3",
keywords: list[str] | None = None,
is_commander: bool = False,
) -> Card:
c = Card(
name=name,
type_line="Legendary Creature" if is_commander else "Creature",
cmc=3,
power=power,
toughness=toughness,
keywords=keywords or [],
is_commander=is_commander,
)
return c
def _make_land(name: str = "Mountain") -> Card:
return Card(name=name, type_line="Basic Land", cmc=0)
def _setup_combat(
attacker_creatures: list[Card],
blocker_creatures: list[Card] | None = None,
attacker_life: int = 40,
defender_life: int = 40,
) -> tuple[GameEngine, SimState, int, int]:
"""Set up a 2-player SimState ready for _resolve_combat.
Returns (engine, sim, attacker_seat, defender_seat).
Attacker is seat 0, defender is seat 1.
All creatures are pre-placed on battlefield, untapped, played on a previous turn.
"""
engine = GameEngine(max_turns=25, starting_life=40, record_log=True)
sim = SimState(max_turns=25)
sim.turn = 5 # so creatures played earlier pass summoning sickness check
p0 = Player(name="Attacker", life=attacker_life, owner_id=0, stats=PlayerStats())
p1 = Player(name="Defender", life=defender_life, owner_id=1, stats=PlayerStats())
sim.players = [p0, p1]
sim.init_battlefields(2)
card_id = 90000
for c in attacker_creatures:
c.owner_id = 0
c.tapped = False
c.turn_played = 1 # played on an earlier turn
c.id = card_id
card_id += 1
sim.add_to_battlefield(0, c)
for c in (blocker_creatures or []):
c.owner_id = 1
c.tapped = False
c.turn_played = 1
c.id = card_id
card_id += 1
sim.add_to_battlefield(1, c)
sim.next_card_id = card_id
return engine, sim, 0, 1
# ── Tests: Basic commander damage tracking ───────────────────
class TestCommanderDamageBasic:
"""Commander creatures attacking unblocked should generate
commander damage tracked on the defending player."""
def test_unblocked_commander_deals_commander_damage(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Life reduced
assert opp.life == 35
# Per-seat aggregate
assert opp.commander_damage_received[pi] == 5
# Per-card breakdown
assert opp.commander_damage_by_card[(pi, "Krenko")] == 5
def test_unblocked_non_commander_no_commander_damage(self):
creature = _make_creature("Goblin", "4", "4", is_commander=False)
engine, sim, pi, oi = _setup_combat([creature])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
assert opp.life == 36
assert opp.commander_damage_received == {}
assert opp.commander_damage_by_card == {}
def test_mixed_attackers_only_commander_tracked(self):
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
grunt = _make_creature("Goblin", "2", "2", is_commander=False)
engine, sim, pi, oi = _setup_combat([cmd, grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 3 + 2 = 5
assert opp.life == 35
# Only commander damage tracked
assert opp.commander_damage_received[pi] == 3
assert opp.commander_damage_by_card[(pi, "Krenko")] == 3
def test_cumulative_commander_damage_across_combats(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
# First combat
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 5
# Reset tapped state for second combat
cmd.tapped = False
sim.turn += 1
engine._resolve_combat(sim, pi, sim.turn, [])
assert sim.players[oi].commander_damage_received[pi] == 10
assert sim.players[oi].commander_damage_by_card[(pi, "Krenko")] == 10
# ── Tests: Commander damage lethal (21+) ─────────────────────
class TestCommanderDamageLethal:
def test_21_damage_eliminates(self):
cmd = _make_creature("Krenko", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd], defender_life=100)
# Deal 7 per combat, need 3 combats = 21
for _ in range(3):
cmd.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert opp.commander_damage_received[pi] == 21
assert opp.eliminated
def test_20_damage_not_lethal(self):
"""20 commander damage should NOT eliminate."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 20}
p.commander_damage_by_card = {(0, "Krenko"): 20}
assert not p.is_dead_to_commander_damage()
def test_21_per_card_not_per_seat(self):
"""With partners, 21 must come from a SINGLE commander, not combined."""
p = Player(name="Test", life=40)
# Two partner commanders from seat 0, 11 each = 22 total but neither is 21
p.commander_damage_by_card = {
(0, "Brallin"): 11,
(0, "Shabraz"): 11,
}
p.commander_damage_received = {0: 22}
assert not p.is_dead_to_commander_damage()
def test_21_from_one_partner_is_lethal(self):
"""If one partner reaches 21, that's lethal even if the other has 0."""
p = Player(name="Test", life=40)
p.commander_damage_by_card = {
(0, "Brallin"): 21,
(0, "Shabraz"): 3,
}
p.commander_damage_received = {0: 24}
assert p.is_dead_to_commander_damage()
def test_backward_compat_no_by_card(self):
"""If commander_damage_by_card is empty, fall back to per-seat check."""
p = Player(name="Test", life=40)
p.commander_damage_received = {0: 21}
# No by_card data (older game state)
assert p.is_dead_to_commander_damage()
# ── Tests: Trample commander damage ──────────────────────────
class TestCommanderDamageTrample:
def test_trample_overflow_is_commander_damage(self):
"""When a commander with trample is chump-blocked, overflow to
the player should count as commander damage."""
cmd = _make_creature("Ghalta", "12", "12", keywords=["trample"], is_commander=True)
# Defender life low enough that AI decides to chump-block
# (opp.life <= a_pow * 2 ⇒ 20 <= 24)
blocker = _make_creature("Bear", "2", "2")
engine, sim, pi, oi = _setup_combat([cmd], [blocker], defender_life=20)
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Bear (2/2) chump-blocks Ghalta (12/12 trample).
# Trample overflow = 12 - 2 = 10 damage to player.
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Ghalta")] == 10
assert opp.life == 10
def test_fully_blocked_commander_no_player_damage(self):
"""Commander fully absorbed by a survivable blocker — no commander
damage to the defending player."""
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
# Blocker toughness > attacker power so AI selects it
blocker = _make_creature("Wall", "0", "5")
engine, sim, pi, oi = _setup_combat([cmd], [blocker])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# No damage to player (absorbed by blocker, no trample)
assert opp.life == 40
assert opp.commander_damage_received == {}
# ── Tests: Double strike commander damage ────────────────────
class TestCommanderDamageDoubleStrike:
def test_unblocked_double_strike_commander(self):
cmd = _make_creature("Rafiq", "5", "5", keywords=["double strike"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Double strike unblocked = power * 2
assert opp.life == 30
assert opp.commander_damage_received[pi] == 10
assert opp.commander_damage_by_card[(pi, "Rafiq")] == 10
# ── Tests: Partner / multi-commander ─────────────────────────
class TestPartnerCommanderDamage:
def test_two_partner_commanders_tracked_separately(self):
cmd_a = _make_creature("Brallin", "4", "4", is_commander=True)
cmd_b = _make_creature("Shabraz", "3", "3", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
opp = sim.players[oi]
# Total damage = 4 + 3 = 7
assert opp.life == 33
# Aggregate per-seat
assert opp.commander_damage_received[pi] == 7
# Per-card individual
assert opp.commander_damage_by_card[(pi, "Brallin")] == 4
assert opp.commander_damage_by_card[(pi, "Shabraz")] == 3
def test_partner_21_check_per_card(self):
"""Even with partners, only 21 from ONE commander kills."""
cmd_a = _make_creature("Brallin", "7", "7", is_commander=True)
cmd_b = _make_creature("Shabraz", "7", "7", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b], defender_life=100)
# 3 combats: 7*3=21 for Brallin, 7*3=21 for Shabraz
# After 2 combats: 14 each — not lethal
for _ in range(2):
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
sim.turn += 1
opp = sim.players[oi]
assert not opp.eliminated # 14 from each, neither is 21
# 3rd combat pushes both to 21
cmd_a.tapped = False
cmd_b.tapped = False
engine._resolve_combat(sim, pi, sim.turn, [])
assert opp.eliminated
# ── Tests: CommanderGameState integration ────────────────────
class TestCommanderGameStateDamage:
def test_deal_commander_damage_with_name(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10, commander_name="Krenko")
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card[(0, "Krenko")] == 10
assert cp.life == 30
def test_deal_commander_damage_without_name_backward_compat(self):
sim = SimState()
sim.init_battlefields(2)
for i in range(2):
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
gs = CommanderGameState.from_sim_state(sim)
gs.deal_commander_damage(0, 1, 10)
cp = gs.commander_players[1]
assert cp.commander_damage_received[0] == 10
assert cp.base.commander_damage_by_card == {}
assert cp.life == 30
def test_commander_player_property_delegates_to_base(self):
"""CommanderPlayer.commander_damage_received should be the same
object as base.commander_damage_received."""
p = Player(name="Test", life=40)
cp = CommanderPlayer(base=p)
# Write through base
p.commander_damage_received[0] = 15
# Read through wrapper
assert cp.commander_damage_received[0] == 15
# Write through wrapper
cp.commander_damage_received[1] = 7
assert p.commander_damage_received[1] == 7
# ── Tests: Event log annotation ──────────────────────────────
class TestCommanderDamageEvents:
def test_event_log_includes_cmdr_annotation(self):
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
# Should mention commander damage in the event text
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "5 cmdr" in damage_event[0]
def test_no_cmdr_annotation_for_non_commanders(self):
grunt = _make_creature("Goblin", "3", "3", is_commander=False)
engine, sim, pi, oi = _setup_combat([grunt])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
damage_event = [e for e in events if "combat damage" in e]
assert len(damage_event) == 1
assert "cmdr" not in damage_event[0]
# ── Tests: Lifelink + commander interaction ──────────────────
class TestCommanderDamageLifelink:
def test_commander_with_lifelink_heals_and_tracks(self):
cmd = _make_creature("Oloro", "5", "5", keywords=["lifelink"], is_commander=True)
engine, sim, pi, oi = _setup_combat([cmd])
events: list[str] = []
engine._resolve_combat(sim, pi, sim.turn, events)
attacker = sim.players[pi]
opp = sim.players[oi]
# Commander damage tracked
assert opp.commander_damage_received[pi] == 5
# Lifelink healed attacker
assert attacker.life == 45
# Defender took damage
assert opp.life == 35
# ── Tests: Player model ─────────────────────────────────────
class TestPlayerCommanderDamageModel:
def test_fresh_player_has_empty_tracking(self):
p = Player(name="Test")
assert p.commander_damage_received == {}
assert p.commander_damage_by_card == {}
assert not p.is_dead_to_commander_damage()
def test_is_dead_prefers_by_card(self):
"""When by_card data exists, per-seat aggregate is ignored for lethal check."""
p = Player(name="Test")
p.commander_damage_received = {0: 25} # aggregate says lethal
p.commander_damage_by_card = {
(0, "CmdA"): 12,
(0, "CmdB"): 13,
}
# Per-card: neither commander has 21 → NOT lethal
assert not p.is_dead_to_commander_damage()