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>
This commit is contained in:
3 files changed
+398
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user