diff --git a/src/commander_ai_lab/sim/engine.py b/src/commander_ai_lab/sim/engine.py index 616c1d1..b0d129c 100644 --- a/src/commander_ai_lab/sim/engine.py +++ b/src/commander_ai_lab/sim/engine.py @@ -438,7 +438,13 @@ class GameEngine: if events is not None: events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures") else: - sim.add_to_battlefield(pi, card) + # Fix #84/BUG-05: instants and sorceries resolve and go to + # graveyard — they are NOT permanents and should never stay + # on the battlefield. + if card.is_permanent(): + sim.add_to_battlefield(pi, card) + else: + self._send_to_graveyard(sim, card, pi) if card.is_creature(): p.stats.creatures_played += 1 if events is not None: @@ -515,7 +521,7 @@ class GameEngine: c for c in sim.get_battlefield(pi) if ( - c.is_creature() + c.can_attack_or_block() and not c.tapped and (c.turn_played < sim.turn or c.has_keyword("haste")) ) @@ -526,7 +532,7 @@ class GameEngine: c for c in sim.get_battlefield(opp_idx) if ( - c.is_creature() + c.can_attack_or_block() and not c.tapped ) ] diff --git a/src/commander_ai_lab/sim/game_state.py b/src/commander_ai_lab/sim/game_state.py index fc53fe7..d8b5f00 100644 --- a/src/commander_ai_lab/sim/game_state.py +++ b/src/commander_ai_lab/sim/game_state.py @@ -227,15 +227,223 @@ class CommanderGameState: def stack_is_empty(self) -> bool: return len(self.stack) == 0 - # Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager - # doesn't crash. These delegate to the rules engine once implemented. + # ── Legal Moves & Apply (BUG-02 fix) ───────────────────── + # These replace the former pass-only stubs so that the turn manager + # can drive real game actions through the CommanderGameState. + def get_legal_moves(self, seat: int) -> list[dict]: - """Return legal moves for a seat. Stub — returns pass-only.""" - return [{"id": 0, "category": "pass_priority", "description": "Pass"}] + """Return legal moves for a seat based on mana, phase, and card type. + + Move categories: + - pass_priority: always available + - play_land: if main phase, land drop not used, and land in hand + - cast_spell: if main phase and castable cards in hand + - attack: if declare_attackers phase and creatures can attack + - instant/activate_ability: if holding-priority phases + """ + moves: list[dict] = [] + move_id = 0 + + if seat >= len(self.commander_players): + return [{"id": 0, "category": "pass_priority", "description": "Pass"}] + + cp = self.commander_players[seat] + player = cp.base + bf = self.sim_state.get_battlefield(seat) + + # Available mana = untapped lands + untapped mana-producing artifacts + available_mana = sum( + 1 for c in bf + if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature())) + ) + + # ── Main phase actions ── + if self.current_phase in ("main1", "main2"): + # Play land (one per turn) + if not self.land_drop_used: + for i, card in enumerate(player.hand): + if card.is_land(): + moves.append({ + "id": move_id, + "category": "play_land", + "description": f"Play land: {card.name}", + "card_index": i, + "card_name": card.name, + }) + move_id += 1 + break # only one land drop offered + + # Cast spells from hand (sorcery-speed: creatures, sorceries, + # artifacts, enchantments, planeswalkers) + for i, card in enumerate(player.hand): + if card.is_land(): + continue + cmc = card.cmc or 0 + if cmc <= available_mana: + moves.append({ + "id": move_id, + "category": "cast_spell", + "description": f"Cast {card.name} (CMC {cmc})", + "card_index": i, + "card_name": card.name, + }) + move_id += 1 + + # Cast commander from command zone + for i, cmd in enumerate(cp.commander_zone): + tax = player.commander_tax.get(cmd.name, 0) + total = (cmd.cmc or 0) + tax + if total <= available_mana: + moves.append({ + "id": move_id, + "category": "cast_spell", + "description": f"Cast commander {cmd.name} (cost {total}, tax {tax})", + "card_name": cmd.name, + "from_zone": "command", + "cmd_index": i, + }) + move_id += 1 + + # ── Combat: declare attackers ── + if self.current_phase == "declare_attackers": + attackable_creatures = [ + c for c in bf + if c.can_attack_or_block() + and not c.tapped + and (c.turn_played < self.turn or c.has_keyword("haste")) + ] + if attackable_creatures: + moves.append({ + "id": move_id, + "category": "attack", + "description": f"Attack with all ({len(attackable_creatures)} creatures)", + "attack_mode": "all", + }) + move_id += 1 + + # ── Instant-speed responses ── + if self.current_phase in ("upkeep", "begin_combat", "end_combat", + "end_step", "declare_blockers"): + for i, card in enumerate(player.hand): + if card.is_instant(): + cmc = card.cmc or 0 + if cmc <= available_mana: + moves.append({ + "id": move_id, + "category": "instant", + "description": f"Cast {card.name} (instant, CMC {cmc})", + "card_index": i, + "card_name": card.name, + }) + move_id += 1 + + # Always allow passing priority + moves.append({ + "id": move_id, + "category": "pass_priority", + "description": "Pass", + }) + + return moves def apply_move(self, seat: int, move_id: int) -> None: - """Apply a chosen move. Stub — no-op until rules engine is wired.""" - pass + """Apply a chosen move to the game state. + + Looks up the move by id in the current legal moves list and + executes the corresponding game action. + """ + legal = self.get_legal_moves(seat) + move = None + for m in legal: + if m["id"] == move_id: + move = m + break + if move is None: + return # invalid move id — no-op + + cat = move["category"] + if cat == "pass_priority": + return + + if seat >= len(self.commander_players): + return + cp = self.commander_players[seat] + player = cp.base + bf = self.sim_state.get_battlefield(seat) + + if cat == "play_land": + idx = move.get("card_index", -1) + if 0 <= idx < len(player.hand): + card = player.hand.pop(idx) + card.owner_id = seat + card.tapped = False + card.id = self.sim_state.next_card_id + self.sim_state.next_card_id += 1 + self.sim_state.add_to_battlefield(seat, card) + self.land_drop_used = True + cp.lands_played_this_turn += 1 + + elif cat == "cast_spell": + from_zone = move.get("from_zone", "hand") + if from_zone == "command": + cmd_idx = move.get("cmd_index", 0) + if cmd_idx < len(cp.commander_zone): + card = cp.commander_zone.pop(cmd_idx) + tax = player.commander_tax.get(card.name, 0) + total_cost = (card.cmc or 0) + tax + card.owner_id = seat + card.tapped = False + card.id = self.sim_state.next_card_id + self.sim_state.next_card_id += 1 + card.turn_played = self.turn + self._tap_mana_sources(seat, total_cost) + self.sim_state.add_to_battlefield(seat, card) + player.commander_tax[card.name] = tax + 2 + else: + idx = move.get("card_index", -1) + if 0 <= idx < len(player.hand): + card = player.hand.pop(idx) + card.owner_id = seat + card.tapped = False + card.id = self.sim_state.next_card_id + self.sim_state.next_card_id += 1 + card.turn_played = self.turn + self._tap_mana_sources(seat, card.cmc or 0) + if card.is_permanent(): + self.sim_state.add_to_battlefield(seat, card) + else: + # Instants/sorceries go to graveyard after resolution + player.graveyard.append(card) + + elif cat == "instant": + idx = move.get("card_index", -1) + if 0 <= idx < len(player.hand): + card = player.hand.pop(idx) + card.owner_id = seat + card.id = self.sim_state.next_card_id + self.sim_state.next_card_id += 1 + self._tap_mana_sources(seat, card.cmc or 0) + # Instants resolve and go to graveyard + player.graveyard.append(card) + + elif cat == "attack": + # Mark all eligible creatures as attacking (tapped) + for c in bf: + if (c.can_attack_or_block() + and not c.tapped + and (c.turn_played < self.turn or c.has_keyword("haste"))): + c.tapped = True + + def _tap_mana_sources(self, seat: int, amount: int) -> None: + """Tap lands and mana rocks to pay a cost.""" + bf = self.sim_state.get_battlefield(seat) + remaining = amount + for c in bf: + if remaining <= 0: + break + if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature())): + c.tapped = True + remaining -= 1 def living_players(self) -> list[CommanderPlayer]: return [p for p in self.commander_players if not p.eliminated] diff --git a/src/commander_ai_lab/sim/models.py b/src/commander_ai_lab/sim/models.py index 30b2069..64b36b0 100644 --- a/src/commander_ai_lab/sim/models.py +++ b/src/commander_ai_lab/sim/models.py @@ -90,6 +90,38 @@ class Card: def is_creature(self) -> bool: return bool(self.type_line and "creature" in self.type_line.lower()) + def is_instant(self) -> bool: + """Check if this card is an Instant.""" + return bool(self.type_line and "instant" in self.type_line.lower()) + + def is_sorcery(self) -> bool: + """Check if this card is a Sorcery.""" + return bool(self.type_line and "sorcery" in self.type_line.lower()) + + def is_permanent(self) -> bool: + """Check if this card is a permanent type (not instant/sorcery). + + Permanents are creatures, artifacts, enchantments, planeswalkers, lands, + and battles. Instants and sorceries are non-permanent spells. + Cards with unknown type are treated as permanents to avoid being + incorrectly discarded. + """ + if not self.type_line: + return True # no type info — keep on battlefield to be safe + tl = self.type_line.lower() + return "instant" not in tl and "sorcery" not in tl + + def can_attack_or_block(self) -> bool: + """Check if this card can participate in combat. + + Per MTG rules, only creatures can attack or block. + Cards with unknown/missing type, artifacts, enchantments, etc. + are NOT eligible for combat unless their type_line includes 'Creature'. + """ + if not self.type_line: + return False + return "creature" in self.type_line.lower() + def clone(self) -> "Card": """Return a deep copy of this card.""" return copy.deepcopy(self) diff --git a/src/commander_ai_lab/sim/rules.py b/src/commander_ai_lab/sim/rules.py index e3672ee..e5b17ca 100644 --- a/src/commander_ai_lab/sim/rules.py +++ b/src/commander_ai_lab/sim/rules.py @@ -167,14 +167,16 @@ def enrich_card(card: Card) -> Card: _apply_oracle_flags(card) return card - # Default: treat as creature with deterministic CMC based on - # card name hash (fixes #32 — no more random.randint jitter - # between games for the same card). + # Default: unknown cards get deterministic CMC based on card + # name hash (fixes #32 — no more random.randint jitter). + # Fix #82: do NOT default to "Creature". Unknown cards are + # classified as "Unknown" and cannot attack/block until their + # real type is confirmed via Scryfall data. if not card.type_line or len(card.type_line) < 3: name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16) estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive estimated_pow = max(1, estimated_cmc - 1) - card.type_line = "Creature" + card.type_line = "Unknown" card.cmc = estimated_cmc card.pt = f"{estimated_pow}/{estimated_pow}" card.power = str(estimated_pow) diff --git a/tests/conftest.py b/tests/conftest.py index 84a250b..7d14211 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -167,6 +167,14 @@ def http_client(fresh_app): return TestClient(fresh_app) +@pytest.fixture +def basic_creature(): + """A simple creature Card for model tests.""" + from commander_ai_lab.sim.models import Card + return Card(name="Grizzly Bears", type_line="Creature — Bear", + cmc=2, pt="2/2", power="2", toughness="2") + + @pytest.fixture def event_loop(): """Provide a fresh event loop for async tests.""" diff --git a/tests/sim/test_engine.py b/tests/sim/test_engine.py index fc18137..dbbc13b 100644 --- a/tests/sim/test_engine.py +++ b/tests/sim/test_engine.py @@ -15,8 +15,9 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]: Card(name="Cultivate"), Card(name="Murder"), Card(name="Wrath of God"), - Card(name="Grizzly Bears"), - Card(name="Serra Angel"), + Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2"), + Card(name="Serra Angel", type_line="Creature — Angel", cmc=5, pt="4/4", + keywords=["Flying", "Vigilance"]), ] for c in card_pool: enrich_card(c) @@ -29,28 +30,30 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]: class TestGameEngineTwoPlayer: def test_game_completes(self): - """A 2-player seeded game should finish without error.""" - engine = GameEngine(seed=42) + """A 2-player game should finish without error.""" + random.seed(42) + engine = GameEngine(max_turns=25) deck_a = _make_deck(seed=1) deck_b = _make_deck(seed=2) - result = engine.run_game( + result = engine.run( deck_a=[c.clone() for c in deck_a], deck_b=[c.clone() for c in deck_b], - player_a_name="Alice", - player_b_name="Bob", + name_a="Alice", + name_b="Bob", ) assert result is not None assert result.turns > 0 def test_result_has_winner_or_draw(self): """winner_seat must be 0, 1, or -1 (draw).""" - engine = GameEngine(seed=99) + random.seed(99) + engine = GameEngine(max_turns=25) deck = _make_deck(seed=5) - result = engine.run_game( + result = engine.run( deck_a=[c.clone() for c in deck], deck_b=[c.clone() for c in deck], - player_a_name="P1", - player_b_name="P2", + name_a="P1", + name_b="P2", ) assert result.winner_seat in (-1, 0, 1) @@ -59,20 +62,22 @@ class TestGameEngineTwoPlayer: deck_a = _make_deck(seed=7) deck_b = _make_deck(seed=8) - engine1 = GameEngine(seed=42) - result1 = engine1.run_game( + random.seed(42) + engine1 = GameEngine(max_turns=25) + result1 = engine1.run( deck_a=[c.clone() for c in deck_a], deck_b=[c.clone() for c in deck_b], - player_a_name="Alice", - player_b_name="Bob", + name_a="Alice", + name_b="Bob", ) - engine2 = GameEngine(seed=42) - result2 = engine2.run_game( + random.seed(42) + engine2 = GameEngine(max_turns=25) + result2 = engine2.run( deck_a=[c.clone() for c in deck_a], deck_b=[c.clone() for c in deck_b], - player_a_name="Alice", - player_b_name="Bob", + name_a="Alice", + name_b="Bob", ) assert result1.winner_seat == result2.winner_seat @@ -81,13 +86,14 @@ class TestGameEngineTwoPlayer: def test_to_dict_serializable(self): """Game result must serialize to a dict with expected keys.""" import json - engine = GameEngine(seed=77) + random.seed(77) + engine = GameEngine(max_turns=25) deck = _make_deck(seed=3) - result = engine.run_game( + result = engine.run( deck_a=[c.clone() for c in deck], deck_b=[c.clone() for c in deck], - player_a_name="A", - player_b_name="B", + name_a="A", + name_b="B", ) d = result.to_dict() assert "winner" in d diff --git a/tests/sim/test_phase1_fixes.py b/tests/sim/test_phase1_fixes.py new file mode 100644 index 0000000..a887b33 --- /dev/null +++ b/tests/sim/test_phase1_fixes.py @@ -0,0 +1,628 @@ +""" +Phase 1 regression tests — BUG-01, BUG-02, BUG-05. + +These tests verify the critical simulation fixes: + BUG-01: Unknown cards no longer default to Creature; combat eligibility + requires 'Creature' in type_line. + BUG-02: get_legal_moves() and apply_move() produce real game actions. + BUG-05: Instants and sorceries go to graveyard after resolution, not + battlefield. +""" +import pytest + +from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState +from commander_ai_lab.sim.rules import enrich_card +from commander_ai_lab.sim.engine import GameEngine +from commander_ai_lab.sim.game_state import ( + CommanderGameState, + CommanderPlayer, + ManaPool, +) + + +# ═══════════════════════════════════════════════════════════════ +# BUG-01: Card type classification & combat eligibility +# ═══════════════════════════════════════════════════════════════ + + +class TestBug01_EnrichCardNoDefaultCreature: + """enrich_card() must NOT turn unknown cards into Creature.""" + + def test_unknown_card_gets_unknown_type(self): + """Card with no Scryfall data should be 'Unknown', not 'Creature'.""" + c = Card(name="Totally Made Up Card") + enrich_card(c) + assert c.type_line == "Unknown" + assert c.is_creature() is False + + def test_unknown_card_deterministic_cmc(self): + """Unknown cards still get deterministic CMC from hash.""" + c = Card(name="Totally Made Up Card") + enrich_card(c) + assert 2 <= c.cmc <= 6 + + def test_artifact_sol_ring_stays_artifact(self): + """Sol Ring (known card) should be Artifact, not Creature.""" + c = Card(name="Sol Ring") + enrich_card(c) + assert c.type_line == "Artifact" + assert c.is_creature() is False + assert c.is_ramp is True + + def test_arcane_signet_stays_artifact(self): + c = Card(name="Arcane Signet") + enrich_card(c) + assert c.type_line == "Artifact" + assert c.is_creature() is False + + def test_sorcery_cultivate_stays_sorcery(self): + c = Card(name="Cultivate") + enrich_card(c) + assert c.type_line == "Sorcery" + assert c.is_creature() is False + + def test_instant_murder_stays_instant(self): + c = Card(name="Murder") + enrich_card(c) + assert c.type_line == "Instant" + assert c.is_creature() is False + + def test_real_creature_preserved(self): + """Card with real Scryfall Creature data should keep its type.""" + c = Card(name="Grizzly Bears", type_line="Creature — Bear", + cmc=2, pt="2/2") + enrich_card(c) + assert c.is_creature() is True + assert c.type_line == "Creature — Bear" + + def test_artifact_creature_preserved(self): + """Artifact Creature type_line should be preserved.""" + c = Card(name="Ornithopter", type_line="Artifact Creature — Thopter", + cmc=0, pt="0/2") + enrich_card(c) + assert c.is_creature() is True + assert "artifact" in c.type_line.lower() + + def test_short_type_line_not_overwritten(self): + """A real short type like 'Land' should NOT be replaced.""" + c = Card(name="Wastes", type_line="Land", cmc=0) + enrich_card(c) + # has_real_data is True because type_line is set and cmc=0 but len > 3 + assert c.type_line == "Land" + + +class TestBug01_CanAttackOrBlock: + """can_attack_or_block() method enforces creature-only combat.""" + + def test_creature_can_attack(self): + c = Card(name="Hill Giant", type_line="Creature — Giant", + cmc=4, pt="3/3") + assert c.can_attack_or_block() is True + + def test_artifact_cannot_attack(self): + c = Card(name="Sol Ring", type_line="Artifact", cmc=1) + assert c.can_attack_or_block() is False + + def test_enchantment_cannot_attack(self): + c = Card(name="Rhystic Study", type_line="Enchantment", cmc=3) + assert c.can_attack_or_block() is False + + def test_instant_cannot_attack(self): + c = Card(name="Lightning Bolt", type_line="Instant", cmc=1) + assert c.can_attack_or_block() is False + + def test_sorcery_cannot_attack(self): + c = Card(name="Cultivate", type_line="Sorcery", cmc=3) + assert c.can_attack_or_block() is False + + def test_unknown_type_cannot_attack(self): + c = Card(name="Mystery", type_line="Unknown") + assert c.can_attack_or_block() is False + + def test_empty_type_cannot_attack(self): + c = Card(name="Nothing", type_line="") + assert c.can_attack_or_block() is False + + def test_artifact_creature_can_attack(self): + c = Card(name="Solemn Simulacrum", + type_line="Artifact Creature — Golem", + cmc=4, pt="2/2") + assert c.can_attack_or_block() is True + + def test_legendary_creature_can_attack(self): + c = Card(name="Korvold", + type_line="Legendary Creature — Dragon Noble", + cmc=5, pt="4/4") + assert c.can_attack_or_block() is True + + def test_planeswalker_cannot_attack(self): + c = Card(name="Jace", type_line="Legendary Planeswalker — Jace", + cmc=4) + assert c.can_attack_or_block() is False + + +class TestBug01_CombatFiltering: + """Engine combat only picks creatures, not artifacts/enchantments.""" + + def _build_sim_with_battlefield(self, cards_p0, cards_p1=None): + """Build a SimState with two players and specific battlefields.""" + sim = SimState(max_turns=25) + p0 = Player(name="Alice", life=40, owner_id=0, + stats=PlayerStats(cards_drawn=7)) + p1 = Player(name="Bob", life=40, owner_id=1, + stats=PlayerStats(cards_drawn=7)) + sim.players = [p0, p1] + sim.init_battlefields(2) + cid = 90000 + for c in cards_p0: + c.id = cid + c.owner_id = 0 + c.tapped = False + c.turn_played = 0 # played on turn 0, so eligible on turn 1+ + cid += 1 + sim.add_to_battlefield(0, c) + for c in (cards_p1 or []): + c.id = cid + c.owner_id = 1 + c.tapped = False + c.turn_played = 0 + cid += 1 + sim.add_to_battlefield(1, c) + sim.next_card_id = cid + return sim + + def test_artifact_does_not_attack(self): + """Sol Ring on battlefield should not be selected as an attacker.""" + creature = Card(name="Hill Giant", type_line="Creature — Giant", + cmc=4, pt="3/3") + artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1) + sim = self._build_sim_with_battlefield([creature, artifact]) + sim.turn = 1 + + engine = GameEngine() + events = [] + engine._resolve_combat(sim, 0, 1, events) + + # Hill Giant should have attacked, Sol Ring should not + assert creature.tapped is True # attacked + assert artifact.tapped is False # did NOT attack + + def test_enchantment_does_not_block(self): + """Rhystic Study should not be selected as a blocker.""" + attacker = Card(name="Hill Giant", type_line="Creature — Giant", + cmc=4, pt="3/3") + enchantment = Card(name="Rhystic Study", type_line="Enchantment", + cmc=3) + blocker_creature = Card(name="Wall of Stone", + type_line="Creature — Wall", + cmc=3, pt="0/8") + + sim = self._build_sim_with_battlefield( + [attacker], + [enchantment, blocker_creature], + ) + sim.turn = 1 + engine = GameEngine() + events = [] + engine._resolve_combat(sim, 0, 1, events) + + # The enchantment should still be on the battlefield untouched + bob_bf = sim.get_battlefield(1) + enchantments = [c for c in bob_bf if c.name == "Rhystic Study"] + assert len(enchantments) == 1 + + +# ═══════════════════════════════════════════════════════════════ +# BUG-02: get_legal_moves() and apply_move() implementation +# ═══════════════════════════════════════════════════════════════ + + +class TestBug02_GetLegalMoves: + """get_legal_moves() returns actual playable actions, not just pass.""" + + def _make_cgs(self, hand=None, bf=None, phase="main1", + land_drop_used=False): + """Build a CommanderGameState for testing.""" + sim = SimState(max_turns=25) + p = Player(name="Alice", life=40, owner_id=0, + hand=hand or [], stats=PlayerStats(cards_drawn=7)) + sim.players = [p] + sim.init_battlefields(1) + cid = 90000 + for c in (bf or []): + c.id = cid + c.owner_id = 0 + c.tapped = False + cid += 1 + sim.add_to_battlefield(0, c) + sim.next_card_id = cid + + cgs = CommanderGameState.from_sim_state(sim) + cgs.current_phase = phase + cgs.land_drop_used = land_drop_used + cgs.turn = 1 + return cgs + + def test_pass_always_available(self): + cgs = self._make_cgs() + moves = cgs.get_legal_moves(0) + pass_moves = [m for m in moves if m["category"] == "pass_priority"] + assert len(pass_moves) >= 1 + + def test_land_in_hand_during_main_phase(self): + land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0) + cgs = self._make_cgs(hand=[land], phase="main1") + moves = cgs.get_legal_moves(0) + land_moves = [m for m in moves if m["category"] == "play_land"] + assert len(land_moves) == 1 + assert "Forest" in land_moves[0]["description"] + + def test_no_land_play_when_drop_used(self): + land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0) + cgs = self._make_cgs(hand=[land], phase="main1", land_drop_used=True) + moves = cgs.get_legal_moves(0) + land_moves = [m for m in moves if m["category"] == "play_land"] + assert len(land_moves) == 0 + + def test_castable_spell_with_mana(self): + spell = Card(name="Grizzly Bears", type_line="Creature — Bear", + cmc=2, pt="2/2") + lands = [ + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + ] + cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1") + moves = cgs.get_legal_moves(0) + cast_moves = [m for m in moves if m["category"] == "cast_spell"] + assert len(cast_moves) == 1 + assert "Grizzly Bears" in cast_moves[0]["description"] + + def test_spell_too_expensive(self): + spell = Card(name="Big Creature", type_line="Creature", cmc=5, + pt="5/5") + lands = [Card(name="Forest", type_line="Basic Land", cmc=0)] + cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1") + moves = cgs.get_legal_moves(0) + cast_moves = [m for m in moves if m["category"] == "cast_spell"] + assert len(cast_moves) == 0 + + def test_no_spells_during_combat(self): + spell = Card(name="Grizzly Bears", type_line="Creature — Bear", + cmc=2, pt="2/2") + lands = [ + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + ] + cgs = self._make_cgs(hand=[spell], bf=lands, + phase="declare_attackers") + moves = cgs.get_legal_moves(0) + cast_moves = [m for m in moves if m["category"] == "cast_spell"] + assert len(cast_moves) == 0 + + def test_attack_move_with_creatures(self): + creature = Card(name="Hill Giant", type_line="Creature — Giant", + cmc=4, pt="3/3", turn_played=0) + cgs = self._make_cgs(bf=[creature], phase="declare_attackers") + moves = cgs.get_legal_moves(0) + atk_moves = [m for m in moves if m["category"] == "attack"] + assert len(atk_moves) == 1 + + def test_no_attack_without_creatures(self): + artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1) + cgs = self._make_cgs(bf=[artifact], phase="declare_attackers") + moves = cgs.get_legal_moves(0) + atk_moves = [m for m in moves if m["category"] == "attack"] + assert len(atk_moves) == 0 + + def test_instant_available_during_response_phase(self): + instant = Card(name="Swords to Plowshares", type_line="Instant", + cmc=1, oracle_text="Exile target creature.") + lands = [Card(name="Plains", type_line="Basic Land — Plains", cmc=0)] + cgs = self._make_cgs(hand=[instant], bf=lands, phase="end_step") + moves = cgs.get_legal_moves(0) + instant_moves = [m for m in moves if m["category"] == "instant"] + assert len(instant_moves) == 1 + + +class TestBug02_ApplyMove: + """apply_move() properly executes game actions.""" + + def _make_cgs(self, hand=None, bf=None, phase="main1", + land_drop_used=False): + sim = SimState(max_turns=25) + p = Player(name="Alice", life=40, owner_id=0, + hand=hand or [], stats=PlayerStats(cards_drawn=7)) + sim.players = [p] + sim.init_battlefields(1) + cid = 90000 + for c in (bf or []): + c.id = cid + c.owner_id = 0 + c.tapped = False + cid += 1 + sim.add_to_battlefield(0, c) + sim.next_card_id = cid + + cgs = CommanderGameState.from_sim_state(sim) + cgs.current_phase = phase + cgs.land_drop_used = land_drop_used + cgs.turn = 1 + return cgs + + def test_play_land_moves_to_battlefield(self): + land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0) + cgs = self._make_cgs(hand=[land], phase="main1") + + moves = cgs.get_legal_moves(0) + land_move = next(m for m in moves if m["category"] == "play_land") + cgs.apply_move(0, land_move["id"]) + + bf = cgs.sim_state.get_battlefield(0) + assert any(c.name == "Forest" for c in bf) + assert len(cgs.commander_players[0].base.hand) == 0 + assert cgs.land_drop_used is True + + def test_cast_creature_to_battlefield(self): + creature = Card(name="Grizzly Bears", type_line="Creature — Bear", + cmc=2, pt="2/2") + lands = [ + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + ] + cgs = self._make_cgs(hand=[creature], bf=lands, phase="main1") + + moves = cgs.get_legal_moves(0) + cast_move = next(m for m in moves if m["category"] == "cast_spell") + cgs.apply_move(0, cast_move["id"]) + + bf = cgs.sim_state.get_battlefield(0) + creature_on_bf = [c for c in bf if c.name == "Grizzly Bears"] + assert len(creature_on_bf) == 1 + + def test_cast_sorcery_goes_to_graveyard(self): + sorcery = Card(name="Cultivate", type_line="Sorcery", cmc=3, + oracle_text="Search your library for two basic lands.") + lands = [ + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + Card(name="Forest", type_line="Basic Land — Forest", cmc=0), + ] + cgs = self._make_cgs(hand=[sorcery], bf=lands, phase="main1") + + moves = cgs.get_legal_moves(0) + cast_move = next(m for m in moves if m["category"] == "cast_spell") + cgs.apply_move(0, cast_move["id"]) + + bf = cgs.sim_state.get_battlefield(0) + sorceries_on_bf = [c for c in bf if c.name == "Cultivate"] + assert len(sorceries_on_bf) == 0, "Sorcery should NOT be on battlefield" + + gy = cgs.commander_players[0].base.graveyard + assert any(c.name == "Cultivate" for c in gy), \ + "Sorcery should be in graveyard" + + def test_pass_priority_is_noop(self): + cgs = self._make_cgs(phase="main1") + moves = cgs.get_legal_moves(0) + pass_move = next(m for m in moves if m["category"] == "pass_priority") + # Should not raise + cgs.apply_move(0, pass_move["id"]) + + +# ═══════════════════════════════════════════════════════════════ +# BUG-05: Instants/sorceries should not remain on the battlefield +# ═══════════════════════════════════════════════════════════════ + + +class TestBug05_SpellResolution: + """Instants and sorceries are routed to graveyard, not battlefield.""" + + def test_is_permanent_creature(self): + c = Card(name="Bear", type_line="Creature — Bear") + assert c.is_permanent() is True + + def test_is_permanent_artifact(self): + c = Card(name="Sol Ring", type_line="Artifact") + assert c.is_permanent() is True + + def test_is_permanent_enchantment(self): + c = Card(name="Rhystic Study", type_line="Enchantment") + assert c.is_permanent() is True + + def test_is_not_permanent_instant(self): + c = Card(name="Lightning Bolt", type_line="Instant") + assert c.is_permanent() is False + + def test_is_not_permanent_sorcery(self): + c = Card(name="Cultivate", type_line="Sorcery") + assert c.is_permanent() is False + + def test_engine_sorcery_not_on_battlefield(self): + """A non-removal, non-wipe sorcery cast via engine goes to graveyard.""" + # Build a deck with a draw sorcery (Harmonize) and lots of lands + harmonize = Card(name="Harmonize") + enrich_card(harmonize) + assert harmonize.type_line == "Sorcery" + + # Build mini sim state + sim = SimState(max_turns=25) + p0 = Player(name="Alice", life=40, owner_id=0, + hand=[harmonize], + stats=PlayerStats(cards_drawn=7)) + sim.players = [p0, Player(name="Bob", life=40, owner_id=1, + stats=PlayerStats(cards_drawn=7))] + sim.init_battlefields(2) + + # Give Alice 5 untapped lands on battlefield + cid = 90000 + for i in range(5): + land = Card(name="Forest", type_line="Basic Land — Forest", + cmc=0, id=cid, owner_id=0, tapped=False) + cid += 1 + sim.add_to_battlefield(0, land) + sim.next_card_id = cid + + engine = GameEngine() + engine._play_spells(sim, 0, 5) + + # Harmonize should NOT be on the battlefield + bf = sim.get_battlefield(0) + sorceries = [c for c in bf if c.name == "Harmonize"] + assert len(sorceries) == 0, "Sorcery should not stay on battlefield" + + # It should be in the graveyard + assert any(c.name == "Harmonize" for c in p0.graveyard), \ + "Sorcery should be in graveyard after resolution" + + def test_engine_artifact_stays_on_battlefield(self): + """An artifact (permanent) should stay on battlefield after being cast.""" + sol_ring = Card(name="Sol Ring") + enrich_card(sol_ring) + + sim = SimState(max_turns=25) + p0 = Player(name="Alice", life=40, owner_id=0, + hand=[sol_ring], + stats=PlayerStats(cards_drawn=7)) + sim.players = [p0, Player(name="Bob", life=40, owner_id=1, + stats=PlayerStats(cards_drawn=7))] + sim.init_battlefields(2) + + cid = 90000 + for i in range(2): + land = Card(name="Forest", type_line="Basic Land — Forest", + cmc=0, id=cid, owner_id=0, tapped=False) + cid += 1 + sim.add_to_battlefield(0, land) + sim.next_card_id = cid + + engine = GameEngine() + engine._play_spells(sim, 0, 2) + + bf = sim.get_battlefield(0) + artifacts = [c for c in bf if c.name == "Sol Ring"] + assert len(artifacts) == 1, "Artifact should remain on battlefield" + + +class TestBug05_IsInstantAndSorcery: + """Card.is_instant() and is_sorcery() helpers.""" + + def test_instant_detected(self): + c = Card(name="Bolt", type_line="Instant") + assert c.is_instant() is True + assert c.is_sorcery() is False + + def test_sorcery_detected(self): + c = Card(name="Wrath", type_line="Sorcery") + assert c.is_sorcery() is True + assert c.is_instant() is False + + def test_creature_not_instant_or_sorcery(self): + c = Card(name="Bear", type_line="Creature — Bear") + assert c.is_instant() is False + assert c.is_sorcery() is False + + def test_empty_type_line(self): + c = Card(name="?") + assert c.is_instant() is False + assert c.is_sorcery() is False + + +# ═══════════════════════════════════════════════════════════════ +# Integration: full game still runs with all fixes applied +# ═══════════════════════════════════════════════════════════════ + + +class TestIntegration: + """Sanity check that a full game can still run with all fixes.""" + + def _make_deck(self, size=60): + """Build a test deck with mixed card types.""" + cards = [] + # 24 lands + for _ in range(24): + c = Card(name="Forest") + enrich_card(c) + cards.append(c) + # 15 creatures (with real type data) + for _ in range(15): + c = Card(name="Grizzly Bears", type_line="Creature — Bear", + cmc=2, pt="2/2") + enrich_card(c) + cards.append(c) + # 5 removal instants + for _ in range(5): + c = Card(name="Murder") + enrich_card(c) + cards.append(c) + # 3 board wipes + for _ in range(3): + c = Card(name="Wrath of God") + enrich_card(c) + cards.append(c) + # 5 artifacts (ramp) + for _ in range(5): + c = Card(name="Sol Ring") + enrich_card(c) + cards.append(c) + # 3 ramp sorceries + for _ in range(3): + c = Card(name="Cultivate") + enrich_card(c) + cards.append(c) + # 5 unknown cards (should be "Unknown" type, not creatures) + for i in range(5): + c = Card(name=f"Mystery Card {i}") + enrich_card(c) + cards.append(c) + return cards + + def test_full_game_completes(self): + """A 2-player game with mixed deck should complete without errors.""" + engine = GameEngine(max_turns=25) + deck_a = self._make_deck() + deck_b = self._make_deck() + result = engine.run( + [c.clone() for c in deck_a], + [c.clone() for c in deck_b], + 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_no_artifacts_in_combat_log(self): + """With record_log, no artifact names should appear in attack events.""" + engine = GameEngine(max_turns=10, record_log=True) + deck_a = self._make_deck() + deck_b = self._make_deck() + result = engine.run( + [c.clone() for c in deck_a], + [c.clone() for c in deck_b], + name_a="Alice", + name_b="Bob", + ) + for turn_entry in result.game_log: + for phase in turn_entry.get("phases", []): + for event in phase.get("events", []): + if "Attacks" in event: + assert "Sol Ring" not in event, \ + f"Sol Ring should not attack: {event}" + + def test_unknown_cards_not_in_combat(self): + """Unknown-type cards should not appear in attack event logs.""" + engine = GameEngine(max_turns=10, record_log=True) + deck_a = self._make_deck() + deck_b = self._make_deck() + result = engine.run( + [c.clone() for c in deck_a], + [c.clone() for c in deck_b], + name_a="Alice", + name_b="Bob", + ) + for turn_entry in result.game_log: + for phase in turn_entry.get("phases", []): + for event in phase.get("events", []): + if "Attacks" in event: + assert "Mystery Card" not in event, \ + f"Unknown card should not attack: {event}" diff --git a/tests/sim/test_rules.py b/tests/sim/test_rules.py index ab15fd8..43f0a1c 100644 --- a/tests/sim/test_rules.py +++ b/tests/sim/test_rules.py @@ -67,10 +67,17 @@ class TestEnrichCardDeterministic: c2 = Card(name="Zzzzz") enrich_card(c1) enrich_card(c2) - # At minimum they are both valid creatures with cmc in [2,6] + # At minimum they are both valid Unknown-type cards with cmc in [2,6] assert 2 <= c1.cmc <= 6 assert 2 <= c2.cmc <= 6 + def test_unknown_card_type_is_unknown(self): + """Unknown cards should get type_line='Unknown', not 'Creature'.""" + c = Card(name="Totally Unknown Card XYZ") + enrich_card(c) + assert c.type_line == "Unknown" + assert c.is_creature() is False + def test_unknown_card_cmc_range(self): for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]: c = Card(name=name)