diff --git a/src/commander_ai_lab/sim/engine.py b/src/commander_ai_lab/sim/engine.py index 7ca911d..0daa3eb 100644 --- a/src/commander_ai_lab/sim/engine.py +++ b/src/commander_ai_lab/sim/engine.py @@ -60,6 +60,9 @@ class GameEngine: ``weights`` explicitly to override (e.g. for A/B testing). """ + # London mulligan: max times a player may mulligan before forced keep + MAX_MULLIGANS = 4 + def __init__( self, max_turns: int = 25, @@ -67,6 +70,7 @@ class GameEngine: weights: Optional[dict] = None, record_log: bool = False, ml_log: bool = False, + mulligan_rule: str = "london", ): self.max_turns = max_turns self.starting_life = starting_life @@ -75,6 +79,7 @@ class GameEngine: self.record_log = record_log self.ml_log = ml_log self.ml_decisions: list[dict] = [] + self.mulligan_rule = mulligan_rule # ────────────────────────────────────────────────────────── # Public API @@ -282,6 +287,42 @@ class GameEngine: # Internal helpers # ────────────────────────────────────────────────────────── + # ────────────────────────────────────────────────────────── + # Opening-hand helpers + # ────────────────────────────────────────────────────────── + + @staticmethod + def _count_lands_in_hand(hand: list[Card]) -> int: + """Count lands in a hand of cards.""" + return sum(1 for c in hand if c.is_land()) + + @staticmethod + def _should_mulligan(hand: list[Card], mulligan_number: int) -> bool: + """Heuristic: mulligan if opening hand has 0-1 lands or 6-7 lands. + + Becomes more lenient with each successive mulligan — after 2+ + mulligans, accept 1-land hands to avoid going too low on cards. + """ + lands = GameEngine._count_lands_in_hand(hand) + total = len(hand) + if mulligan_number >= 2: + # Desperate: keep anything with 1-5 lands + return lands < 1 or lands >= total + # Standard: keep 2-5 lands in a 7-card hand + return lands <= 1 or lands >= 6 + + @staticmethod + def _pick_bottom_cards(hand: list[Card], count: int, weights: dict) -> list[Card]: + """Choose the worst *count* cards to put on the bottom of library. + + Uses the AI scoring function so the least valuable cards are + bottomed — mirrors a real player's London mulligan decision. + """ + if count <= 0 or count >= len(hand): + return [] + scored = sorted(hand, key=lambda c: score_card(c, weights)) + return scored[:count] + def _create_state( self, decks: list[list[Card]], @@ -290,6 +331,11 @@ class GameEngine: ) -> SimState: """Create initial simulation state with shuffled decks and opening hands (N players). + Implements the London mulligan rule: each player draws 7, decides + whether to keep, and if not shuffles back and redraws. After + keeping, they put N cards on the bottom of their library where N + is the number of times they mulliganed. + If commander_names is provided, the matching card is flagged is_commander=True and placed in the player's command_zone instead of the library/hand. @@ -308,16 +354,46 @@ class GameEngine: commander_card = c else: remaining.append(c) - random.shuffle(remaining) - hand = remaining[:7] + + # ── London mulligan loop ── + mulligan_count = 0 + if self.mulligan_rule == "london": + for attempt in range(self.MAX_MULLIGANS): + random.shuffle(remaining) + hand = remaining[:7] + if not self._should_mulligan(hand, attempt): + break + mulligan_count += 1 + else: + # Exhausted all mulligans — forced keep with last draw + random.shuffle(remaining) + hand = remaining[:7] + else: + # No mulligan (legacy behavior) + random.shuffle(remaining) + hand = remaining[:7] + library = remaining[7:] + + # ── Bottom N cards for London mulligan ── + if mulligan_count > 0: + bottom = self._pick_bottom_cards(hand, mulligan_count, self.weights) + for card in bottom: + hand.remove(card) + library.insert(0, card) # bottom of library + + opening_lands = self._count_lands_in_hand(hand) player = Player( name=name, life=self.starting_life, owner_id=idx, library=library, hand=hand, - stats=PlayerStats(cards_drawn=7), + stats=PlayerStats( + cards_drawn=7, + mulligans=mulligan_count, + opening_hand_lands=opening_lands, + ), ) if commander_card: player.command_zone.append(commander_card) diff --git a/src/commander_ai_lab/sim/models.py b/src/commander_ai_lab/sim/models.py index bf24f1f..adaf299 100644 --- a/src/commander_ai_lab/sim/models.py +++ b/src/commander_ai_lab/sim/models.py @@ -102,6 +102,8 @@ class PlayerStats: """Per-game statistics tracked for a player.""" cards_drawn: int = 7 # starting hand + mulligans: int = 0 + opening_hand_lands: int = 0 lands_played: int = 0 spells_cast: int = 0 creatures_played: int = 0 @@ -117,6 +119,8 @@ class PlayerStats: def to_dict(self) -> dict: return { "cardsDrawn": self.cards_drawn, + "mulligans": self.mulligans, + "openingHandLands": self.opening_hand_lands, "landsPlayed": self.lands_played, "spellsCast": self.spells_cast, "creaturesPlayed": self.creatures_played, diff --git a/tests/sim/test_mulligan.py b/tests/sim/test_mulligan.py new file mode 100644 index 0000000..768393e --- /dev/null +++ b/tests/sim/test_mulligan.py @@ -0,0 +1,315 @@ +"""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"]