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