Refactor the headless engine to walk through all 12 MTG turn phases in order (untap → upkeep → draw → main1 → begin_combat → declare_attackers → declare_blockers → combat_damage → end_combat → main2 → end_step → cleanup) instead of the previous flat per-player block. Key changes: - Add Phase str enum and PHASE_ORDER/SORCERY_PHASES constants to models - Track current_phase and active_player_index on SimState - Sync sim.turn with the loop turn counter (fixes latent summoning- sickness bug where turn_played was always compared against 0) - Add second main phase (main2) with shared 2-spell budget across main1 + main2 - _play_spells now accepts max_spells param and returns count played - Unify turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES to derive from the canonical Phase enum in models.py - 33 regression tests covering phase enum, phase progression, spell budget, game log structure, backward compat, and turn manager constant consistency Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
516 lines
20 KiB
Python
516 lines
20 KiB
Python
"""Regression tests for explicit turn-structure and phase progression.
|
|
|
|
Validates that the headless engine walks through all MTG phases in order,
|
|
tracks the current phase on SimState, supports a shared spell budget across
|
|
main1+main2, and preserves all prior behavior (combat, elimination, logging).
|
|
"""
|
|
|
|
import random
|
|
import pytest
|
|
from commander_ai_lab.sim.models import (
|
|
Card,
|
|
Phase,
|
|
PHASE_ORDER,
|
|
SORCERY_PHASES,
|
|
SimState,
|
|
)
|
|
from commander_ai_lab.sim.rules import enrich_card
|
|
from commander_ai_lab.sim.engine import GameEngine
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────
|
|
|
|
def _make_deck(seed: int, size: int = 60) -> list[Card]:
|
|
"""Deterministic test deck."""
|
|
rng = random.Random(seed)
|
|
pool = [
|
|
Card(name="Forest"),
|
|
Card(name="Sol Ring"),
|
|
Card(name="Cultivate"),
|
|
Card(name="Murder"),
|
|
Card(name="Wrath of God"),
|
|
Card(name="Grizzly Bears"),
|
|
Card(name="Serra Angel"),
|
|
]
|
|
for c in pool:
|
|
enrich_card(c)
|
|
deck = [pool[i % len(pool)].clone() for i in range(size)]
|
|
rng.shuffle(deck)
|
|
return deck
|
|
|
|
|
|
def _land_heavy_deck(size: int = 60) -> list[Card]:
|
|
"""Deck with many lands (ensures mana for spells in both main phases)."""
|
|
cards = []
|
|
for i in range(size):
|
|
if i < 30:
|
|
c = Card(name="Forest")
|
|
elif i < 40:
|
|
c = Card(name="Grizzly Bears")
|
|
elif i < 50:
|
|
c = Card(name="Serra Angel")
|
|
else:
|
|
c = Card(name="Sol Ring")
|
|
enrich_card(c)
|
|
cards.append(c)
|
|
random.Random(42).shuffle(cards)
|
|
return cards
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# Phase Enum Tests
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
class TestPhaseEnum:
|
|
"""Tests for the Phase enum and related constants."""
|
|
|
|
def test_phase_count(self):
|
|
"""There should be exactly 12 phases in MTG turn structure."""
|
|
assert len(Phase) == 12
|
|
|
|
def test_phase_order_matches_mtg(self):
|
|
"""Phases must follow the MTG turn order."""
|
|
expected = [
|
|
"untap", "upkeep", "draw", "main1",
|
|
"begin_combat", "declare_attackers", "declare_blockers",
|
|
"combat_damage", "end_combat",
|
|
"main2", "end_step", "cleanup",
|
|
]
|
|
assert [p.value for p in PHASE_ORDER] == expected
|
|
|
|
def test_phase_is_str_subclass(self):
|
|
"""Phase enum values should compare directly with plain strings."""
|
|
assert Phase.MAIN1 == "main1"
|
|
assert Phase.CLEANUP == "cleanup"
|
|
|
|
def test_sorcery_phases(self):
|
|
"""Only main1 and main2 are sorcery-speed phases."""
|
|
assert SORCERY_PHASES == frozenset({Phase.MAIN1, Phase.MAIN2})
|
|
|
|
def test_phase_iteration(self):
|
|
"""PHASE_ORDER is iterable and matches Phase members."""
|
|
assert list(PHASE_ORDER) == list(Phase)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# SimState Phase Tracking
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
class TestSimStatePhaseTracking:
|
|
"""Tests that SimState tracks phase and active player index."""
|
|
|
|
def test_default_phase(self):
|
|
"""SimState defaults to MAIN1."""
|
|
sim = SimState()
|
|
assert sim.current_phase == Phase.MAIN1
|
|
|
|
def test_default_active_player(self):
|
|
"""SimState defaults to player 0 as active."""
|
|
sim = SimState()
|
|
assert sim.active_player_index == 0
|
|
|
|
def test_phase_updates_during_game(self):
|
|
"""After a game, current_phase should have been set (not stuck at default)."""
|
|
engine = GameEngine(max_turns=1, mulligan_rule="none")
|
|
deck_a = _make_deck(seed=10)
|
|
deck_b = _make_deck(seed=11)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck_a],
|
|
deck_b=[c.clone() for c in deck_b],
|
|
)
|
|
# Game completed successfully
|
|
assert result.turns >= 1
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# Phase Progression in Engine
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
class TestPhaseProgression:
|
|
"""Tests that the engine walks through phases correctly."""
|
|
|
|
def test_game_completes_with_phase_structure(self):
|
|
"""Game should complete without errors using the new phase loop."""
|
|
engine = GameEngine(max_turns=10)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in _make_deck(1)],
|
|
deck_b=[c.clone() for c in _make_deck(2)],
|
|
name_a="Alice",
|
|
name_b="Bob",
|
|
)
|
|
assert result is not None
|
|
assert result.turns > 0
|
|
assert result.winner_seat in (-1, 0, 1)
|
|
|
|
def test_four_player_game_with_phases(self):
|
|
"""4-player game should complete with the new phase loop."""
|
|
engine = GameEngine(max_turns=10)
|
|
decks = [_make_deck(seed=i) for i in range(4)]
|
|
result = engine.run_n(
|
|
decks=[[c.clone() for c in d] for d in decks],
|
|
names=["A", "B", "C", "D"],
|
|
)
|
|
assert result is not None
|
|
assert result.turns > 0
|
|
assert len(result.players) == 4
|
|
|
|
def test_untap_happens_each_turn(self):
|
|
"""Creatures tapped for combat are untapped at start of next turn."""
|
|
engine = GameEngine(max_turns=5, record_log=True, mulligan_rule="none")
|
|
# Build deck with creatures that will attack
|
|
deck = []
|
|
for i in range(60):
|
|
if i < 25:
|
|
deck.append(Card(name="Forest"))
|
|
else:
|
|
c = Card(name="Grizzly Bears")
|
|
enrich_card(c)
|
|
deck.append(c)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
# Game should complete — untap logic is working
|
|
assert result.turns > 0
|
|
|
|
def test_draw_happens_after_untap(self):
|
|
"""Players draw one card each turn after the first."""
|
|
engine = GameEngine(max_turns=3, mulligan_rule="none")
|
|
deck_a = _make_deck(seed=20, size=60)
|
|
deck_b = _make_deck(seed=21, size=60)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck_a],
|
|
deck_b=[c.clone() for c in deck_b],
|
|
)
|
|
# Both players should have drawn cards (starting 7 + draws)
|
|
for pr in result.players:
|
|
assert pr.stats.cards_drawn >= 7
|
|
|
|
def test_land_drop_in_main1(self):
|
|
"""Land drops should still happen (in main1)."""
|
|
engine = GameEngine(max_turns=5, mulligan_rule="none")
|
|
deck = _make_deck(seed=30)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
# At least one player should have played lands
|
|
total_lands = sum(pr.stats.lands_played for pr in result.players)
|
|
assert total_lands > 0
|
|
|
|
def test_combat_in_declare_attackers_phase(self):
|
|
"""Combat damage should still resolve correctly."""
|
|
engine = GameEngine(max_turns=25, mulligan_rule="none")
|
|
# Use creature-heavy deck so combat is guaranteed
|
|
deck = []
|
|
for i in range(60):
|
|
if i < 24:
|
|
deck.append(Card(name="Forest"))
|
|
else:
|
|
c = Card(name="Grizzly Bears")
|
|
enrich_card(c)
|
|
deck.append(c)
|
|
random.Random(40).shuffle(deck)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
total_damage = sum(pr.stats.damage_dealt for pr in result.players)
|
|
# With creature-heavy decks over 25 turns, combat must happen
|
|
assert total_damage > 0
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# Spell Budget Across Main Phases
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
class TestSpellBudget:
|
|
"""Tests that the 2-spell budget is shared across main1 and main2."""
|
|
|
|
def test_max_two_spells_per_turn(self):
|
|
"""No player should cast more than 2 non-commander spells per turn."""
|
|
engine = GameEngine(max_turns=5, mulligan_rule="none")
|
|
deck = _land_heavy_deck()
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
# Both players played spells
|
|
for pr in result.players:
|
|
# spells_cast across all turns; with 5 turns max 10 spells each
|
|
assert pr.stats.spells_cast <= 10 # 2 per turn * 5 turns
|
|
|
|
def test_spells_can_split_across_main_phases(self):
|
|
"""Engine supports casting in main2 if budget allows.
|
|
|
|
We verify by checking that _play_spells returns 0 when budget is 0.
|
|
"""
|
|
engine = GameEngine()
|
|
# Create a minimal sim state with one player
|
|
sim = SimState(max_turns=1)
|
|
from commander_ai_lab.sim.models import Player, PlayerStats
|
|
p = Player(
|
|
name="Test",
|
|
life=40,
|
|
owner_id=0,
|
|
hand=[],
|
|
stats=PlayerStats(),
|
|
)
|
|
sim.players.append(p)
|
|
sim.init_battlefields(1)
|
|
|
|
# With no cards in hand, should play 0 spells
|
|
result = engine._play_spells(sim, 0, available_mana=5, max_spells=2)
|
|
assert result == 0
|
|
|
|
def test_play_spells_respects_max_spells_zero(self):
|
|
"""_play_spells with max_spells=0 plays nothing."""
|
|
engine = GameEngine()
|
|
sim = SimState(max_turns=1)
|
|
from commander_ai_lab.sim.models import Player, PlayerStats
|
|
p = Player(
|
|
name="Test",
|
|
life=40,
|
|
owner_id=0,
|
|
hand=[Card(name="Grizzly Bears", cmc=2, type_line="Creature")],
|
|
stats=PlayerStats(),
|
|
)
|
|
sim.players.append(p)
|
|
sim.init_battlefields(1)
|
|
# Add 5 untapped lands
|
|
for _ in range(5):
|
|
land = Card(name="Forest", type_line="Land", tapped=False)
|
|
sim.add_to_battlefield(0, land)
|
|
|
|
result = engine._play_spells(sim, 0, available_mana=5, max_spells=0)
|
|
assert result == 0
|
|
# Card should still be in hand
|
|
assert len(p.hand) == 1
|
|
|
|
def test_play_spells_respects_max_spells_one(self):
|
|
"""_play_spells with max_spells=1 plays at most one spell."""
|
|
engine = GameEngine()
|
|
sim = SimState(max_turns=1)
|
|
from commander_ai_lab.sim.models import Player, PlayerStats
|
|
bear1 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
|
|
bear2 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
|
|
enrich_card(bear1)
|
|
enrich_card(bear2)
|
|
p = Player(
|
|
name="Test",
|
|
life=40,
|
|
owner_id=0,
|
|
hand=[bear1, bear2],
|
|
stats=PlayerStats(),
|
|
)
|
|
sim.players.append(p)
|
|
sim.init_battlefields(1)
|
|
for _ in range(5):
|
|
land = Card(name="Forest", type_line="Land", tapped=False)
|
|
sim.add_to_battlefield(0, land)
|
|
|
|
result = engine._play_spells(sim, 0, available_mana=5, max_spells=1)
|
|
assert result == 1
|
|
assert len(p.hand) == 1 # one spell remaining
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# Game Log with Phase Structure
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
class TestGameLogPhases:
|
|
"""Tests that the game log still works with the new phase structure."""
|
|
|
|
def test_game_log_has_turns(self):
|
|
"""record_log should produce a log with turn entries."""
|
|
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
|
|
deck = _make_deck(seed=50)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
assert result.game_log is not None
|
|
assert len(result.game_log) > 0
|
|
|
|
def test_game_log_turn_structure(self):
|
|
"""Each log entry should have turn number and phases list."""
|
|
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
|
|
deck = _make_deck(seed=51)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
for entry in result.game_log:
|
|
assert "turn" in entry
|
|
assert "phases" in entry
|
|
assert isinstance(entry["phases"], list)
|
|
|
|
def test_game_log_phase_entries(self):
|
|
"""Phase entries should have player and events."""
|
|
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
|
|
deck = _make_deck(seed=52)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
for entry in result.game_log:
|
|
for phase in entry["phases"]:
|
|
assert "player" in phase
|
|
assert "playerId" in phase
|
|
assert "events" in phase
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# Backward Compatibility
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
class TestBackwardCompat:
|
|
"""Ensure existing behavior is preserved."""
|
|
|
|
def test_two_player_still_works(self):
|
|
"""The 2-player run() entry point should still work."""
|
|
engine = GameEngine(max_turns=10)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in _make_deck(60)],
|
|
deck_b=[c.clone() for c in _make_deck(61)],
|
|
)
|
|
assert result.winner_seat in (-1, 0, 1)
|
|
|
|
def test_n_player_still_works(self):
|
|
"""The N-player run_n() entry point should still work."""
|
|
engine = GameEngine(max_turns=10)
|
|
decks = [_make_deck(seed=i + 70) for i in range(3)]
|
|
result = engine.run_n(
|
|
decks=[[c.clone() for c in d] for d in decks],
|
|
names=["X", "Y", "Z"],
|
|
)
|
|
assert result.winner_seat in range(3)
|
|
assert len(result.players) == 3
|
|
|
|
def test_mulligan_rule_still_works(self):
|
|
"""London mulligan should still function with the phase refactor."""
|
|
engine = GameEngine(max_turns=5, mulligan_rule="london")
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in _make_deck(80)],
|
|
deck_b=[c.clone() for c in _make_deck(81)],
|
|
)
|
|
assert result.turns > 0
|
|
|
|
def test_no_mulligan_rule_still_works(self):
|
|
"""Legacy mulligan='none' should still function."""
|
|
engine = GameEngine(max_turns=5, mulligan_rule="none")
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in _make_deck(90)],
|
|
deck_b=[c.clone() for c in _make_deck(91)],
|
|
)
|
|
assert result.turns > 0
|
|
|
|
def test_commander_damage_still_tracked(self):
|
|
"""Commander damage tracking should still work through deal_damage."""
|
|
sim = SimState(max_turns=1)
|
|
from commander_ai_lab.sim.models import Player, PlayerStats
|
|
for i in range(2):
|
|
sim.players.append(Player(
|
|
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
|
|
))
|
|
sim.init_battlefields(2)
|
|
|
|
commander = Card(name="TestCommander", is_commander=True)
|
|
GameEngine.deal_damage(
|
|
sim, 5, target_seat=1,
|
|
source_card=commander, source_seat=0,
|
|
is_combat=True,
|
|
)
|
|
assert sim.players[1].commander_damage_received[0] == 5
|
|
assert sim.players[1].commander_damage_by_card[(0, "TestCommander")] == 5
|
|
assert sim.players[1].life == 35
|
|
|
|
def test_direct_damage_still_works(self):
|
|
"""Burn spells should still route through deal_damage."""
|
|
sim = SimState(max_turns=1)
|
|
from commander_ai_lab.sim.models import Player, PlayerStats
|
|
for i in range(2):
|
|
sim.players.append(Player(
|
|
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
|
|
))
|
|
sim.init_battlefields(2)
|
|
|
|
burn = Card(name="Lightning Bolt", is_direct_damage=True, direct_damage_amount=3)
|
|
events: list[str] = []
|
|
GameEngine.deal_damage(
|
|
sim, 3, target_seat=1,
|
|
source_card=burn, source_seat=0,
|
|
events=events,
|
|
label="Lightning Bolt deals 3",
|
|
)
|
|
assert sim.players[1].life == 37
|
|
assert len(events) == 1
|
|
|
|
def test_elimination_still_works(self):
|
|
"""Player elimination should still trigger when life <= 0."""
|
|
engine = GameEngine(max_turns=25, mulligan_rule="none")
|
|
# Build aggro-heavy decks
|
|
deck = []
|
|
for i in range(60):
|
|
if i < 20:
|
|
deck.append(Card(name="Mountain"))
|
|
else:
|
|
c = Card(name="Lightning Bolt")
|
|
enrich_card(c)
|
|
deck.append(c)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
# At least one player should be eliminated (or game timed out)
|
|
assert result.turns > 0
|
|
|
|
def test_ml_log_still_works(self):
|
|
"""ML logging should still capture decisions."""
|
|
engine = GameEngine(max_turns=3, ml_log=True, mulligan_rule="none")
|
|
deck = _make_deck(seed=100)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
decisions = engine.flush_ml_decisions()
|
|
assert isinstance(decisions, list)
|
|
# With turns happening, there should be some decisions
|
|
if result.turns > 0:
|
|
assert len(decisions) > 0
|
|
|
|
def test_result_serializable(self):
|
|
"""Game result should still serialize cleanly."""
|
|
import json
|
|
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
|
|
deck = _make_deck(seed=110)
|
|
result = engine.run(
|
|
deck_a=[c.clone() for c in deck],
|
|
deck_b=[c.clone() for c in deck],
|
|
)
|
|
d = result.to_dict()
|
|
json.dumps(d) # should not raise
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# Turn Manager Phase Constants
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
class TestTurnManagerPhaseCompat:
|
|
"""Ensure turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES are consistent."""
|
|
|
|
def test_turn_manager_phases_match_model(self):
|
|
"""turn_manager.PHASES should match the canonical PHASE_ORDER."""
|
|
from commander_ai_lab.sim.turn_manager import PHASES as TM_PHASES
|
|
assert TM_PHASES == [p.value for p in PHASE_ORDER]
|
|
|
|
def test_turn_manager_action_phases(self):
|
|
"""ACTION_PHASES should be main1 and main2."""
|
|
from commander_ai_lab.sim.turn_manager import ACTION_PHASES
|
|
assert ACTION_PHASES == {"main1", "main2"}
|
|
|
|
def test_turn_manager_response_phases(self):
|
|
"""RESPONSE_PHASES should be upkeep, begin_combat, end_combat, end_step."""
|
|
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
|
|
assert RESPONSE_PHASES == {"upkeep", "begin_combat", "end_combat", "end_step"}
|