Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ded23aef |
No files matched your search
@@ -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
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user