Compare commits

...
Author SHA1 Message Date
ClaudeandClaude Opus 4.6 77ded23aef fix: Phase 1 critical sim fixes — BUG-01, BUG-02, BUG-05 (#82, #84)
BUG-01: Unknown cards no longer default to "Creature". enrich_card() now
sets type_line="Unknown" for cards without Scryfall data. Added
can_attack_or_block() method to Card — only cards with "Creature" in
type_line can participate in combat. Engine combat uses
can_attack_or_block() instead of is_creature().

BUG-02: Implemented get_legal_moves() and apply_move() in
CommanderGameState with actual game logic — land drops, spell casting
(with mana checking), commander casting from command zone, attack
declarations, and instant-speed responses. Replaces the former
pass-only stubs.

BUG-05: Instants and sorceries now go to graveyard after resolution
instead of being placed on the battlefield as permanents. Added
is_permanent(), is_instant(), is_sorcery() helpers to Card model.

Includes 48 new regression tests covering all three bugs plus
integration tests verifying artifacts/unknown cards never appear in
combat. Fixed existing test_engine.py to match current engine API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:00:11 +00:00
8 changed files with 934 additions and 37 deletions

No files matched your search

+9 -3
View File
@@ -438,7 +438,13 @@ class GameEngine:
if events is not None: if events is not None:
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures") events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
else: 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(): if card.is_creature():
p.stats.creatures_played += 1 p.stats.creatures_played += 1
if events is not None: if events is not None:
@@ -515,7 +521,7 @@ class GameEngine:
c c
for c in sim.get_battlefield(pi) for c in sim.get_battlefield(pi)
if ( if (
c.is_creature() c.can_attack_or_block()
and not c.tapped and not c.tapped
and (c.turn_played < sim.turn or c.has_keyword("haste")) and (c.turn_played < sim.turn or c.has_keyword("haste"))
) )
@@ -526,7 +532,7 @@ class GameEngine:
c c
for c in sim.get_battlefield(opp_idx) for c in sim.get_battlefield(opp_idx)
if ( if (
c.is_creature() c.can_attack_or_block()
and not c.tapped and not c.tapped
) )
] ]
+214 -6
View File
@@ -227,15 +227,223 @@ class CommanderGameState:
def stack_is_empty(self) -> bool: def stack_is_empty(self) -> bool:
return len(self.stack) == 0 return len(self.stack) == 0
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager # ── Legal Moves & Apply (BUG-02 fix) ─────────────────────
# doesn't crash. These delegate to the rules engine once implemented. # 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]: def get_legal_moves(self, seat: int) -> list[dict]:
"""Return legal moves for a seat. Stub — returns pass-only.""" """Return legal moves for a seat based on mana, phase, and card type.
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
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: def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move. Stub — no-op until rules engine is wired.""" """Apply a chosen move to the game state.
pass
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]: def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated] return [p for p in self.commander_players if not p.eliminated]
+32
View File
@@ -90,6 +90,38 @@ class Card:
def is_creature(self) -> bool: def is_creature(self) -> bool:
return bool(self.type_line and "creature" in self.type_line.lower()) 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": def clone(self) -> "Card":
"""Return a deep copy of this card.""" """Return a deep copy of this card."""
return copy.deepcopy(self) return copy.deepcopy(self)
+6 -4
View File
@@ -167,14 +167,16 @@ def enrich_card(card: Card) -> Card:
_apply_oracle_flags(card) _apply_oracle_flags(card)
return card return card
# Default: treat as creature with deterministic CMC based on # Default: unknown cards get deterministic CMC based on card
# card name hash (fixes #32 — no more random.randint jitter # name hash (fixes #32 — no more random.randint jitter).
# between games for the same card). # 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: if not card.type_line or len(card.type_line) < 3:
name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16) name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16)
estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive
estimated_pow = max(1, estimated_cmc - 1) estimated_pow = max(1, estimated_cmc - 1)
card.type_line = "Creature" card.type_line = "Unknown"
card.cmc = estimated_cmc card.cmc = estimated_cmc
card.pt = f"{estimated_pow}/{estimated_pow}" card.pt = f"{estimated_pow}/{estimated_pow}"
card.power = str(estimated_pow) card.power = str(estimated_pow)
+8
View File
@@ -167,6 +167,14 @@ def http_client(fresh_app):
return TestClient(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 @pytest.fixture
def event_loop(): def event_loop():
"""Provide a fresh event loop for async tests.""" """Provide a fresh event loop for async tests."""
+29 -23
View File
@@ -15,8 +15,9 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
Card(name="Cultivate"), Card(name="Cultivate"),
Card(name="Murder"), Card(name="Murder"),
Card(name="Wrath of God"), Card(name="Wrath of God"),
Card(name="Grizzly Bears"), Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2"),
Card(name="Serra Angel"), Card(name="Serra Angel", type_line="Creature — Angel", cmc=5, pt="4/4",
keywords=["Flying", "Vigilance"]),
] ]
for c in card_pool: for c in card_pool:
enrich_card(c) enrich_card(c)
@@ -29,28 +30,30 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
class TestGameEngineTwoPlayer: class TestGameEngineTwoPlayer:
def test_game_completes(self): def test_game_completes(self):
"""A 2-player seeded game should finish without error.""" """A 2-player game should finish without error."""
engine = GameEngine(seed=42) random.seed(42)
engine = GameEngine(max_turns=25)
deck_a = _make_deck(seed=1) deck_a = _make_deck(seed=1)
deck_b = _make_deck(seed=2) deck_b = _make_deck(seed=2)
result = engine.run_game( result = engine.run(
deck_a=[c.clone() for c in deck_a], deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b], deck_b=[c.clone() for c in deck_b],
player_a_name="Alice", name_a="Alice",
player_b_name="Bob", name_b="Bob",
) )
assert result is not None assert result is not None
assert result.turns > 0 assert result.turns > 0
def test_result_has_winner_or_draw(self): def test_result_has_winner_or_draw(self):
"""winner_seat must be 0, 1, or -1 (draw).""" """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) deck = _make_deck(seed=5)
result = engine.run_game( result = engine.run(
deck_a=[c.clone() for c in deck], deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck], deck_b=[c.clone() for c in deck],
player_a_name="P1", name_a="P1",
player_b_name="P2", name_b="P2",
) )
assert result.winner_seat in (-1, 0, 1) assert result.winner_seat in (-1, 0, 1)
@@ -59,20 +62,22 @@ class TestGameEngineTwoPlayer:
deck_a = _make_deck(seed=7) deck_a = _make_deck(seed=7)
deck_b = _make_deck(seed=8) deck_b = _make_deck(seed=8)
engine1 = GameEngine(seed=42) random.seed(42)
result1 = engine1.run_game( engine1 = GameEngine(max_turns=25)
result1 = engine1.run(
deck_a=[c.clone() for c in deck_a], deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b], deck_b=[c.clone() for c in deck_b],
player_a_name="Alice", name_a="Alice",
player_b_name="Bob", name_b="Bob",
) )
engine2 = GameEngine(seed=42) random.seed(42)
result2 = engine2.run_game( engine2 = GameEngine(max_turns=25)
result2 = engine2.run(
deck_a=[c.clone() for c in deck_a], deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b], deck_b=[c.clone() for c in deck_b],
player_a_name="Alice", name_a="Alice",
player_b_name="Bob", name_b="Bob",
) )
assert result1.winner_seat == result2.winner_seat assert result1.winner_seat == result2.winner_seat
@@ -81,13 +86,14 @@ class TestGameEngineTwoPlayer:
def test_to_dict_serializable(self): def test_to_dict_serializable(self):
"""Game result must serialize to a dict with expected keys.""" """Game result must serialize to a dict with expected keys."""
import json import json
engine = GameEngine(seed=77) random.seed(77)
engine = GameEngine(max_turns=25)
deck = _make_deck(seed=3) deck = _make_deck(seed=3)
result = engine.run_game( result = engine.run(
deck_a=[c.clone() for c in deck], deck_a=[c.clone() for c in deck],
deck_b=[c.clone() for c in deck], deck_b=[c.clone() for c in deck],
player_a_name="A", name_a="A",
player_b_name="B", name_b="B",
) )
d = result.to_dict() d = result.to_dict()
assert "winner" in d assert "winner" in d
+628
View File
@@ -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}"
+8 -1
View File
@@ -67,10 +67,17 @@ class TestEnrichCardDeterministic:
c2 = Card(name="Zzzzz") c2 = Card(name="Zzzzz")
enrich_card(c1) enrich_card(c1)
enrich_card(c2) 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 <= c1.cmc <= 6
assert 2 <= c2.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): def test_unknown_card_cmc_range(self):
for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]: for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]:
c = Card(name=name) c = Card(name=name)