Files
commander-ai-lab/tests/sim/test_mulligan.py
T
5e9c568bcc feat: implement London mulligan and opening-hand validation (#86) (#91) (#91)
Add London mulligan rule to the headless game engine:

- GameEngine now accepts mulligan_rule param ("london" default, "none" for legacy)
- AI heuristic: mulligan 0-1 land or 6-7 land hands; relax after 2+ mulligans
- After keeping, bottom N lowest-scored cards (London mulligan rule)
- Cap at MAX_MULLIGANS=4 to prevent infinite loops on degenerate decks
- Track mulligans and opening_hand_lands in PlayerStats
- Serialize both fields in to_dict() for batch-result schema compatibility
- 28 targeted regression tests covering unit helpers, integration,
  N-player, commander interaction, serialization, and legacy mode

Co-authored-by: Claude Agent <claude-agent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:28:01 -04:00

316 lines
12 KiB
Python

"""Tests for London mulligan and opening-hand validation (#86)."""
import random
import pytest
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.models import Card, PlayerStats
from commander_ai_lab.sim.rules import enrich_card
# ── Helpers ───────────────────────────────────────────────────
def _land(name: str = "Forest") -> Card:
return enrich_card(Card(name=name))
def _spell(name: str = "Grizzly Bears") -> Card:
return enrich_card(Card(name=name))
def _make_deck(lands: int = 24, spells: int = 36) -> list[Card]:
"""Build a deck with an exact land/spell split."""
deck: list[Card] = []
for _ in range(lands):
deck.append(_land())
for _ in range(spells):
deck.append(_spell())
return deck
def _all_lands_deck(size: int = 60) -> list[Card]:
"""Deck of nothing but lands — forces 7-land opening hand."""
return [_land() for _ in range(size)]
def _no_lands_deck(size: int = 60) -> list[Card]:
"""Deck with zero lands — forces 0-land opening hand."""
return [_spell(f"Spell_{i}") for i in range(size)]
# ── PlayerStats fields ────────────────────────────────────────
class TestPlayerStatsMultiganFields:
def test_default_mulligans_zero(self):
stats = PlayerStats()
assert stats.mulligans == 0
def test_default_opening_hand_lands_zero(self):
stats = PlayerStats()
assert stats.opening_hand_lands == 0
def test_to_dict_includes_mulligan_keys(self):
stats = PlayerStats(mulligans=2, opening_hand_lands=3)
d = stats.to_dict()
assert d["mulligans"] == 2
assert d["openingHandLands"] == 3
# ── _should_mulligan heuristic ────────────────────────────────
class TestShouldMulligan:
def test_zero_lands_mulligans(self):
hand = [_spell() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_one_land_mulligans(self):
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_two_lands_keeps(self):
hand = [_land(), _land()] + [_spell() for _ in range(5)]
assert GameEngine._should_mulligan(hand, 0) is False
def test_five_lands_keeps(self):
hand = [_land() for _ in range(5)] + [_spell(), _spell()]
assert GameEngine._should_mulligan(hand, 0) is False
def test_six_lands_mulligans(self):
hand = [_land() for _ in range(6)] + [_spell()]
assert GameEngine._should_mulligan(hand, 0) is True
def test_seven_lands_mulligans(self):
hand = [_land() for _ in range(7)]
assert GameEngine._should_mulligan(hand, 0) is True
def test_desperate_keeps_one_land(self):
"""After 2+ mulligans, accept a 1-land hand."""
hand = [_land()] + [_spell() for _ in range(6)]
assert GameEngine._should_mulligan(hand, 2) is False
# ── _count_lands_in_hand ──────────────────────────────────────
class TestCountLandsInHand:
def test_empty_hand(self):
assert GameEngine._count_lands_in_hand([]) == 0
def test_mixed_hand(self):
hand = [_land(), _spell(), _land(), _spell()]
assert GameEngine._count_lands_in_hand(hand) == 2
def test_all_lands(self):
hand = [_land() for _ in range(7)]
assert GameEngine._count_lands_in_hand(hand) == 7
# ── _pick_bottom_cards ────────────────────────────────────────
class TestPickBottomCards:
def test_returns_correct_count(self):
hand = [_land(), _land(), _spell(), _spell(), _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 2, {})
assert len(bottom) == 2
def test_zero_count_returns_empty(self):
hand = [_land(), _spell()]
assert GameEngine._pick_bottom_cards(hand, 0, {}) == []
def test_count_gte_hand_returns_empty(self):
hand = [_land()]
assert GameEngine._pick_bottom_cards(hand, 1, {}) == []
def test_bottoms_lowest_scored_cards(self):
"""The card with the worst AI score should be picked to bottom."""
good = enrich_card(Card(name="Serra Angel", type_line="Creature", cmc=5, pt="4/4"))
bad = enrich_card(Card(name="Forest"))
hand = [good, bad, _spell()]
bottom = GameEngine._pick_bottom_cards(hand, 1, {})
# Lands score very low compared to creatures
assert bottom[0].name == "Forest"
# ── London mulligan integration ───────────────────────────────
class TestLondonMulliganIntegration:
def test_good_hand_no_mulligan(self):
"""A well-balanced deck should usually keep on the first try."""
random.seed(42)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
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 kept without mulligan
any_kept = any(
p.stats.mulligans == 0 for p in result.players
)
# With a 24-land deck, most seeds give keepable hands
assert result.turns > 0 # game still works
def test_all_lands_deck_triggers_mulligan(self):
"""A 60-land deck should always mulligan (7 lands in hand)."""
random.seed(99)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_lands],
deck_b=[c.clone() for c in deck_normal],
)
# Player A has an all-land deck; must have mulliganed at least once
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_no_lands_deck_triggers_mulligan(self):
"""A 0-land deck should always mulligan (0 lands in hand)."""
random.seed(77)
deck_empty = _no_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_normal],
)
pa_stats = result.players[0].stats
assert pa_stats.mulligans > 0
def test_mulligan_caps_at_max(self):
"""Even a terrible deck can mulligan at most MAX_MULLIGANS times."""
random.seed(11)
deck_empty = _no_lands_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck_empty],
deck_b=[c.clone() for c in deck_empty],
)
for p in result.players:
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
def test_hand_size_after_mulligan(self):
"""After N mulligans, opening hand should be 7 - N cards."""
random.seed(55)
deck_lands = _all_lands_deck()
deck_normal = _make_deck()
engine = GameEngine(mulligan_rule="london")
# We need to test the internal state, so use _create_state directly
sim = engine._create_state(
decks=[[c.clone() for c in deck_lands], [c.clone() for c in deck_normal]],
names=["LandPlayer", "NormalPlayer"],
)
for p in sim.players:
expected = 7 - p.stats.mulligans
assert len(p.hand) == expected, (
f"{p.name}: hand={len(p.hand)}, mulligans={p.stats.mulligans}, "
f"expected={expected}"
)
def test_opening_hand_lands_tracked(self):
"""opening_hand_lands should reflect final kept hand."""
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["P1", "P2"],
)
for p in sim.players:
actual_lands = sum(1 for c in p.hand if c.is_land())
assert p.stats.opening_hand_lands == actual_lands
def test_total_cards_preserved(self):
"""Mulligan must not create or destroy cards."""
random.seed(33)
deck = _make_deck(lands=24, spells=36)
engine = GameEngine(mulligan_rule="london")
for _ in range(10):
sim = engine._create_state(
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
names=["A", "B"],
)
for p in sim.players:
total = len(p.hand) + len(p.library) + len(p.command_zone)
assert total == 60, f"Card count mismatch: {total}"
# ── mulligan_rule="none" (legacy) ─────────────────────────────
class TestNoMulliganMode:
def test_no_mulligan_always_keeps_seven(self):
"""With mulligan_rule='none', always get 7 cards, 0 mulligans."""
random.seed(42)
deck = _all_lands_deck()
engine = GameEngine(mulligan_rule="none")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
)
p = sim.players[0]
assert len(p.hand) == 7
assert p.stats.mulligans == 0
# ── N-player mulligan ─────────────────────────────────────────
class TestNPlayerMulligan:
def test_four_player_mulligan(self):
"""Each player in a 4-player game resolves mulligans independently."""
random.seed(42)
decks = [_make_deck() for _ in range(4)]
engine = GameEngine(mulligan_rule="london")
result = engine.run_n(
decks=[[c.clone() for c in d] for d in decks],
names=["A", "B", "C", "D"],
)
assert len(result.players) == 4
for p in result.players:
assert p.stats.mulligans >= 0
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
# ── Commander + mulligan interaction ──────────────────────────
class TestCommanderMulligan:
def test_commander_stays_in_command_zone_through_mulligans(self):
"""Commander should remain in command zone regardless of mulligans."""
random.seed(42)
# Build a deck with a known commander
deck = _make_deck(lands=24, spells=35)
commander = Card(name="Kenrith, the Returned King", type_line="Creature", cmc=5, pt="5/5")
deck.append(commander)
engine = GameEngine(mulligan_rule="london")
sim = engine._create_state(
decks=[[c.clone() for c in deck]],
names=["P1"],
commander_names=["Kenrith, the Returned King"],
)
p = sim.players[0]
assert len(p.command_zone) == 1
assert p.command_zone[0].name == "Kenrith, the Returned King"
assert p.command_zone[0].is_commander is True
# Commander should not appear in hand or library
assert all(not c.is_commander for c in p.hand)
assert all(not c.is_commander for c in p.library)
# ── Serialization round-trip ──────────────────────────────────
class TestMulliganSerialization:
def test_result_dict_includes_mulligans(self):
"""to_dict() output should include mulligan stats."""
import json
random.seed(42)
deck = _make_deck()
engine = GameEngine(mulligan_rule="london")
result = engine.run(
deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck],
)
d = result.to_dict()
# Verify JSON-serializable
json.dumps(d)
for pr in d["playerResults"]:
assert "mulligans" in pr["stats"]
assert "openingHandLands" in pr["stats"]