846 lines
30 KiB
Python
846 lines
30 KiB
Python
"""
|
|
MTG Rules Engine - Main Rules Engine Module
|
|
|
|
Core rules engine for an online Magic: The Gathering application.
|
|
Enforces rule compliance during gameplay by validating:
|
|
- Card casting (type, mana cost, timing)
|
|
- Creature combat (attackers, blockers, damage assignment)
|
|
- Spell resolution (targets, costs, effects)
|
|
- Zone transitions (enters/leaves battlefield, stack)
|
|
- Keyword ability interactions
|
|
- State-based actions (lethal damage, legend rules)
|
|
|
|
Built on the hardcoded keyword database from keywords_db.py
|
|
and the validator from keyword_validator.py.
|
|
"""
|
|
|
|
from .keywords_db import (
|
|
KEYWORD_ABILITIES,
|
|
KEYWORD_ACTIONS,
|
|
KEYWORD_VARIANTS,
|
|
KEYWORD_TYPES,
|
|
RULES_INDEX,
|
|
get_keyword_definition,
|
|
get_all_keywords,
|
|
)
|
|
from .keyword_validator import KeywordValidator, KeywordValidationError
|
|
|
|
|
|
# =============================================================================
|
|
# DATA MODELS
|
|
# =============================================================================
|
|
|
|
class CardType:
|
|
"""Card types in Magic: The Gathering."""
|
|
CREATURE = "creature"
|
|
SPELL = "spell"
|
|
LAND = "land"
|
|
ENCHANTMENT = "enchantment"
|
|
ARTIFACT = "artifact"
|
|
PLANEWALKER = "planeswalker"
|
|
INSTANT = "instant"
|
|
SORCERY = "sorcery"
|
|
BATTLE = "battle"
|
|
CONSPIRACY = "conspiracy"
|
|
SCHEME = "scheme"
|
|
PLANE = "plane"
|
|
PHENOMENON = "phenomenon"
|
|
VANGUARD = "vanguard"
|
|
SAGA = "saga"
|
|
OMEGA = "omega"
|
|
CASE = "case"
|
|
DUNGEON = "dungeon"
|
|
ROOM = "room"
|
|
DOOR = "door"
|
|
|
|
|
|
class CardTypeGroup:
|
|
"""Groups of card types."""
|
|
CREATURES = {CardType.CREATURE}
|
|
PERMANENTS = {
|
|
CardType.CREATURE,
|
|
CardType.LAND,
|
|
CardType.ENCHANTMENT,
|
|
CardType.ARTIFACT,
|
|
CardType.PLANEWALKER,
|
|
}
|
|
SPELLS = {CardType.INSTANT, CardType.SORCERY}
|
|
PERMANENT_SPELLS = {CardType.LAND, CardType.ENCHANTMENT, CardType.ARTIFACT, CardType.PLANEWALKER}
|
|
|
|
|
|
class Zone:
|
|
"""Game zones."""
|
|
HAND = "hand"
|
|
LIBRARY = "library"
|
|
EXILE = "exile"
|
|
GRAVEYARD = "graveyard"
|
|
COMMAND_ZONE = "command_zone"
|
|
BATTLEFIELD = "battlefield"
|
|
STACK = "stack"
|
|
PLANAR_DECK = "planar_deck"
|
|
SCHEME_DECK = "scheme_deck"
|
|
DUNGEON = "dungeon"
|
|
|
|
|
|
class Phase:
|
|
"""Turn phases."""
|
|
UNTAP = "untap"
|
|
DRAW = "draw"
|
|
MAIN_PHASE = "main_phase"
|
|
PRECOMBAT = "precombat"
|
|
COMBAT = "combat"
|
|
BEGINNING_COMBAT = "beginning_combat"
|
|
DECLARE_ATTACKERS = "declare_attackers"
|
|
DECLARE_BLOCKERS = "declare_blockers"
|
|
COMBAT_DAMAGE = "combat_damage"
|
|
END_COMBAT = "end_combat"
|
|
END_PHASE = "end_phase"
|
|
END_STEP = "end_step"
|
|
CLEANUP = "cleanup"
|
|
|
|
|
|
class ZoneTransitionEvent:
|
|
"""Represents an object moving between zones."""
|
|
def __init__(self, object_id: str, from_zone: str, to_zone: str, object_type: str = None):
|
|
self.object_id = object_id
|
|
self.from_zone = from_zone
|
|
self.to_zone = to_zone
|
|
self.object_type = object_type
|
|
|
|
def is_enters_battlefield(self) -> bool:
|
|
return self.to_zone == Zone.BATTLEFIELD
|
|
|
|
def is_leaves_battlefield(self) -> bool:
|
|
return self.from_zone == Zone.BATTLEFIELD
|
|
|
|
|
|
class GameAction:
|
|
"""Represents a game action that may need validation."""
|
|
def __init__(self, action_type: str, details: dict = None):
|
|
self.action_type = action_type
|
|
self.details = details or {}
|
|
|
|
def __repr__(self):
|
|
return f"GameAction({self.action_type}, {self.details})"
|
|
|
|
|
|
# =============================================================================
|
|
# ZONE MANAGEMENT
|
|
# =============================================================================
|
|
|
|
class ZoneManager:
|
|
"""Manages object positions in game zones."""
|
|
|
|
def __init__(self):
|
|
self.zones = {zone: [] for zone in Zone.__dict__.values() if not zone.startswith("_")}
|
|
|
|
def add_object(self, object_id: str, zone: str, object_type: str = None):
|
|
"""Add an object to a zone."""
|
|
if zone not in self.zones:
|
|
raise ValueError(f"Invalid zone: {zone}")
|
|
self.zones[zone].append({
|
|
"id": object_id,
|
|
"type": object_type,
|
|
"added_at": len(self.zones[zone]),
|
|
})
|
|
|
|
def remove_object(self, object_id: str, zone: str) -> dict | None:
|
|
"""Remove an object from a zone. Returns the object data or None."""
|
|
if zone not in self.zones:
|
|
return None
|
|
for i, obj in enumerate(self.zones[zone]):
|
|
if obj["id"] == object_id:
|
|
return self.zones[zone].pop(i)
|
|
return None
|
|
|
|
def get_objects_in_zone(self, zone: str) -> list[dict]:
|
|
"""Get all objects in a zone."""
|
|
return self.zones.get(zone, [])
|
|
|
|
def get_object(self, object_id: str) -> dict | None:
|
|
"""Find an object by ID across all zones."""
|
|
for zone_objs in self.zones.values():
|
|
for obj in zone_objs:
|
|
if obj["id"] == object_id:
|
|
return obj, zone
|
|
return None
|
|
|
|
def transition(self, object_id: str, from_zone: str, to_zone: str, object_type: str = None) -> ZoneTransitionEvent:
|
|
"""
|
|
Transition an object between zones.
|
|
|
|
Returns:
|
|
ZoneTransitionEvent with the transition details.
|
|
"""
|
|
obj = self.remove_object(object_id, from_zone)
|
|
if obj is None:
|
|
raise ValueError(f"Object {object_id} not found in zone {from_zone}")
|
|
|
|
self.add_object(object_id, to_zone, object_type)
|
|
return ZoneTransitionEvent(object_id, from_zone, to_zone, object_type)
|
|
|
|
|
|
# =============================================================================
|
|
# CARD PARSE AND VALIDATE
|
|
# =============================================================================
|
|
|
|
class CardParser:
|
|
"""
|
|
Parse and validate card data.
|
|
|
|
Extracts card type, mana cost, keywords, and abilities from card text.
|
|
Validates the card against MTG rules.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.validator = KeywordValidator()
|
|
|
|
def parse_card(self, card_data: dict) -> dict:
|
|
"""
|
|
Parse and validate card data.
|
|
|
|
Args:
|
|
card_data: Dictionary with card information (name, type, text, mana_cost, etc.)
|
|
|
|
Returns:
|
|
Validated card dictionary.
|
|
|
|
Raises:
|
|
KeywordValidationError: If the card has invalid keyword usage.
|
|
"""
|
|
validated = {
|
|
"id": card_data.get("id", "unknown"),
|
|
"name": card_data.get("name", "Unknown"),
|
|
"type": card_data.get("type", "unknown").lower(),
|
|
"mana_cost": card_data.get("mana_cost", ""),
|
|
"text": card_data.get("text", ""),
|
|
"keywords": self._extract_keywords(card_data.get("text", "")),
|
|
"abilities": self._extract_abilities(card_data.get("text", "")),
|
|
"is_valid": True,
|
|
"errors": [],
|
|
}
|
|
|
|
# Validate card type
|
|
if validated["type"] not in CardTypeGroup.CREATURES and validated["type"] not in CardTypeGroup.SPELLS:
|
|
validated["is_valid"] = False
|
|
validated["errors"].append(f"Invalid card type: {validated['type']}")
|
|
|
|
# Validate keywords
|
|
if validated["text"]:
|
|
validation_results = self.validator.validate_card_text(validated["text"])
|
|
for result in validation_results:
|
|
if not result["valid"]:
|
|
validated["is_valid"] = False
|
|
validated["errors"].append(
|
|
f"Invalid keyword usage: {result['keyword']}"
|
|
)
|
|
|
|
return validated
|
|
|
|
def _extract_keywords(self, text: str) -> list[str]:
|
|
"""Extract keywords from card text."""
|
|
keywords = set()
|
|
for kw in get_all_keywords():
|
|
if kw.lower() in text.lower():
|
|
keywords.add(kw)
|
|
return sorted(keywords)
|
|
|
|
def _extract_abilities(self, text: str) -> list[dict]:
|
|
"""Extract abilities from card text."""
|
|
abilities = []
|
|
# Simple extraction - in production, use NLP/parsing
|
|
for line in text.split("\n"):
|
|
line = line.strip()
|
|
if line:
|
|
# Check for keyword abilities
|
|
for kw, data in KEYWORD_ABILITIES.items():
|
|
if kw in line.lower():
|
|
abilities.append({
|
|
"keyword": kw,
|
|
"text": line,
|
|
"definition": data["definition"],
|
|
"rule": data["rule"],
|
|
"type": data["type"],
|
|
})
|
|
return abilities
|
|
|
|
def validate_casting(self, card: dict, player_state: dict) -> dict:
|
|
"""
|
|
Validate if a card can be cast.
|
|
|
|
Args:
|
|
card: The validated card dictionary.
|
|
player_state: Dictionary with player's current state (mana, life, etc.)
|
|
|
|
Returns:
|
|
Validation result with errors and warnings.
|
|
"""
|
|
result = {
|
|
"can_cast": True,
|
|
"errors": [],
|
|
"warnings": [],
|
|
}
|
|
|
|
# Validate card type for casting
|
|
if card["type"] not in CardTypeGroup.SPELLS and card["type"] not in CardTypeGroup.PERMANENT_SPELLS:
|
|
result["can_cast"] = False
|
|
result["errors"].append(f"Card type '{card['type']}' cannot be cast")
|
|
return result
|
|
|
|
# Validate timing (instant vs sorcery)
|
|
if card["type"] == CardType.SORCERY:
|
|
if "main_phase" not in player_state.get("current_phase", ""):
|
|
result["can_cast"] = False
|
|
result["errors"].append("Sorceries can only be cast during the main phase")
|
|
|
|
# Validate mana cost
|
|
mana_cost = card.get("mana_cost", "")
|
|
if mana_cost:
|
|
required_mana = self._parse_mana_cost(mana_cost)
|
|
available_mana = player_state.get("available_mana", {})
|
|
|
|
if required_mana and not self._can_pay_cost(required_mana, available_mana):
|
|
result["can_cast"] = False
|
|
result["errors"].append(
|
|
f"Insufficient mana. Required: {required_mana}, Available: {available_mana}"
|
|
)
|
|
|
|
return result
|
|
|
|
def _parse_mana_cost(self, cost: str) -> dict:
|
|
"""Parse a mana cost string into a structured format."""
|
|
# Simple parser - production would use regex
|
|
costs = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0, "E": 0, "X": 0}
|
|
import re
|
|
for match in re.finditer(r"\{([WUBRGCEEXS]|[\d]+)\}", cost):
|
|
sym = match.group(1)
|
|
if sym in costs:
|
|
costs[sym] += 1
|
|
elif sym.isdigit():
|
|
costs["C"] += int(sym)
|
|
elif sym == "E":
|
|
costs["E"] += 1
|
|
return {k: v for k, v in costs.items() if v > 0}
|
|
|
|
def _can_pay_cost(self, required: dict, available: dict) -> bool:
|
|
"""Check if a mana cost can be paid."""
|
|
total_required = sum(required.values())
|
|
total_available = sum(available.values())
|
|
return total_available >= total_required
|
|
|
|
|
|
# =============================================================================
|
|
# COMBAT RESOLVER
|
|
# =============================================================================
|
|
|
|
class CombatResolver:
|
|
"""
|
|
Resolves combat phase according to MTG rules.
|
|
|
|
Handles:
|
|
- Attacker declaration
|
|
- Blocker declaration
|
|
- Combat damage assignment
|
|
- Death resolution (deathtouch, lethal damage)
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.validator = KeywordValidator()
|
|
self.zone_manager = ZoneManager()
|
|
|
|
def resolve_turn(self, game_state: dict) -> dict:
|
|
"""
|
|
Resolve a full turn.
|
|
|
|
Args:
|
|
game_state: Dictionary with game state information.
|
|
|
|
Returns:
|
|
Turn resolution results.
|
|
"""
|
|
results = {
|
|
"phases_resolved": [],
|
|
"actions_taken": [],
|
|
"errors": [],
|
|
}
|
|
|
|
# Untap phase
|
|
results["phases_resolved"].append("untap")
|
|
results["actions_taken"].append(self._untap_phase(game_state))
|
|
|
|
# Draw phase
|
|
results["phases_resolved"].append("draw")
|
|
results["actions_taken"].append(self._draw_phase(game_state))
|
|
|
|
# Main phase
|
|
results["phases_resolved"].append("main_phase")
|
|
results["actions_taken"].append(self._main_phase(game_state))
|
|
|
|
# Pre-combat
|
|
results["phases_resolved"].append("precombat")
|
|
|
|
# Combat phase
|
|
if game_state.get("has_combat"):
|
|
results["phases_resolved"].append("combat")
|
|
combat_result = self._resolve_combat(game_state)
|
|
results["actions_taken"].append(combat_result)
|
|
|
|
# End phase
|
|
results["phases_resolved"].append("end_phase")
|
|
results["actions_taken"].append(self._end_phase(game_state))
|
|
|
|
return results
|
|
|
|
def _untap_phase(self, game_state: dict) -> dict:
|
|
"""Resolve the untap phase."""
|
|
return {"action": "untap", "untapped": game_state.get("untapped_permanents", [])}
|
|
|
|
def _draw_phase(self, game_state: dict) -> dict:
|
|
"""Resolve the draw phase."""
|
|
draw_amount = game_state.get("draw_amount", 1)
|
|
return {"action": "draw", "drawn": draw_amount}
|
|
|
|
def _main_phase(self, game_state: dict) -> dict:
|
|
"""Resolve the main phase (player actions)."""
|
|
# Player takes actions here - simplified
|
|
return {"action": "main_phase", "actions": game_state.get("player_actions", [])}
|
|
|
|
def _resolve_combat(self, game_state: dict) -> dict:
|
|
"""Resolve the combat phase."""
|
|
result = {
|
|
"action": "combat",
|
|
"attackers": [],
|
|
"blockers": [],
|
|
"damage_assigned": [],
|
|
"deaths": [],
|
|
"errors": [],
|
|
}
|
|
|
|
# Get attackers and blockers
|
|
attackers = game_state.get("attackers", [])
|
|
blockers = game_state.get("blockers", [])
|
|
|
|
# Validate attackers
|
|
for attacker in attackers:
|
|
validation = self._validate_attacker(attacker, game_state)
|
|
if validation["valid"]:
|
|
result["attackers"].append(attacker)
|
|
else:
|
|
result["errors"].extend(validation["errors"])
|
|
|
|
# Validate blockers
|
|
for blocker in blockers:
|
|
validation = self._validate_blocker(blocker, game_state)
|
|
if validation["valid"]:
|
|
result["blockers"].append(blocker)
|
|
else:
|
|
result["errors"].extend(validation["errors"])
|
|
|
|
# Assign combat damage
|
|
result["damage_assigned"] = self._assign_damage(attackers, blockers, game_state)
|
|
|
|
# Process deaths
|
|
result["deaths"] = self._process_deaths(game_state)
|
|
|
|
return result
|
|
|
|
def _validate_attacker(self, attacker: dict, game_state: dict) -> dict:
|
|
"""Validate an attacker."""
|
|
result = {"valid": True, "errors": []}
|
|
|
|
# Check for haste (summoning sickness)
|
|
if not attacker.get("haste") and not attacker.get("untapped"):
|
|
result["valid"] = False
|
|
result["errors"].append(f"Attacker {attacker.get('id')} has summoning sickness")
|
|
|
|
# Check if attacker is a creature
|
|
if attacker.get("type") != "creature":
|
|
result["valid"] = False
|
|
result["errors"].append(f"Attacker {attacker.get('id')} is not a creature")
|
|
|
|
return result
|
|
|
|
def _validate_blocker(self, blocker: dict, game_state: dict) -> dict:
|
|
"""Validate a blocker."""
|
|
result = {"valid": True, "errors": []}
|
|
|
|
# Check if blocker is a creature
|
|
if blocker.get("type") != "creature":
|
|
result["valid"] = False
|
|
result["errors"].append(f"Blocker {blocker.get('id')} is not a creature")
|
|
return result
|
|
|
|
# Check evasion abilities
|
|
attackers = game_state.get("attackers", [])
|
|
blocker_evasion = blocker.get("evasion", [])
|
|
|
|
for attacker in attackers:
|
|
attacker_evasion = attacker.get("evasion", [])
|
|
|
|
if not self._blocker_can_block(blocker_evasion, attacker_evasion):
|
|
result["valid"] = False
|
|
result["errors"].append(
|
|
f"Blocker {blocker.get('id')} can't block attacker {attacker.get('id')} "
|
|
f"due to evasion abilities"
|
|
)
|
|
break
|
|
|
|
return result
|
|
|
|
def _blocker_can_block(self, blocker_evasion: list[str], attacker_evasion: list[str]) -> bool:
|
|
"""Check if a blocker can block an attacker based on evasion abilities."""
|
|
if not attacker_evasion:
|
|
return True
|
|
|
|
for ev in attacker_evasion:
|
|
if ev == "flying":
|
|
if "flying" not in blocker_evasion and "reach" not in blocker_evasion:
|
|
return False
|
|
elif ev == "shadow":
|
|
if "shadow" not in blocker_evasion:
|
|
return False
|
|
elif ev == "horsemanship":
|
|
if "horsemanship" not in blocker_evasion:
|
|
return False
|
|
elif ev == "intimidate":
|
|
if "intimidate" not in blocker_evasion:
|
|
return False
|
|
elif ev == "flanking":
|
|
if "flanking" not in blocker_evasion:
|
|
return False
|
|
elif ev == "skulk":
|
|
# Skulk: can't be blocked by creatures with greater power
|
|
# We don't have power info here, so we assume it can block
|
|
pass
|
|
elif ev == "menace":
|
|
if len(blocker_evasion) < 2:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _assign_damage(self, attackers: list[dict], blockers: list[dict], game_state: dict) -> list[dict]:
|
|
"""Assign combat damage."""
|
|
damage_assigned = []
|
|
|
|
for attacker in attackers:
|
|
attacker_power = attacker.get("power", 0)
|
|
attacker_id = attacker.get("id")
|
|
|
|
# Check for trample
|
|
has_trample = "trample" in attacker.get("keywords", [])
|
|
trample_over_pws = "trample_over_planeswalkers" in attacker.get("keywords", [])
|
|
|
|
# Assign damage to blockers first
|
|
for blocker in blockers:
|
|
blocker_id = blocker.get("id")
|
|
blocker_toughness = blocker.get("toughness", 0)
|
|
damage_to_blocker = min(attacker_power, blocker_toughness)
|
|
|
|
damage_assigned.append({
|
|
"attacker_id": attacker_id,
|
|
"target_id": blocker_id,
|
|
"damage": damage_to_blocker,
|
|
"type": "combat",
|
|
})
|
|
|
|
attacker_power -= damage_to_blocker
|
|
if attacker_power <= 0:
|
|
break
|
|
|
|
# Assign remaining damage to defender (trample)
|
|
if attacker_power > 0 and (has_trample or trample_over_pws):
|
|
defender_life = game_state.get("defender_life", 20)
|
|
damage_to_defender = min(attacker_power, defender_life)
|
|
damage_assigned.append({
|
|
"attacker_id": attacker_id,
|
|
"target_id": "defender",
|
|
"damage": damage_to_defender,
|
|
"type": "combat",
|
|
})
|
|
|
|
return damage_assigned
|
|
|
|
def _process_deaths(self, game_state: dict) -> list[str]:
|
|
"""Process state-based death resolution."""
|
|
deaths = []
|
|
|
|
# Check for lethal damage
|
|
for damage in game_state.get("damage_assigned", []):
|
|
target = damage.get("target_id")
|
|
amount = damage.get("damage", 0)
|
|
|
|
if target == "defender":
|
|
# Player takes damage
|
|
game_state["defender_life"] -= amount
|
|
if game_state["defender_life"] <= 0:
|
|
deaths.append("defender")
|
|
else:
|
|
# Creature takes damage
|
|
target_info = game_state.get("targets", {}).get(target)
|
|
if target_info:
|
|
toughness = target_info.get("toughness", 0)
|
|
damage_marked = target_info.get("damage_marked", 0) + amount
|
|
|
|
# Check for deathtouch
|
|
attacker_id = damage.get("attacker_id")
|
|
attacker_has_deathtouch = any(
|
|
k in game_state.get("attackers", {}).get(attacker_id, {}).get("keywords", [])
|
|
for k in ["deathtouch"]
|
|
)
|
|
|
|
if attacker_has_deathtouch:
|
|
# Deathtouch: any nonzero damage is lethal
|
|
if amount > 0:
|
|
deaths.append(target)
|
|
else:
|
|
# Normal damage: check for lethal
|
|
if damage_marked >= toughness:
|
|
deaths.append(target)
|
|
|
|
return deaths
|
|
|
|
def _end_phase(self, game_state: dict) -> dict:
|
|
"""Resolve the end phase."""
|
|
return {"action": "end_phase", "life": game_state.get("defender_life", 20)}
|
|
|
|
|
|
# =============================================================================
|
|
# MAIN RULES ENGINE
|
|
# =============================================================================
|
|
|
|
class RulesEngine:
|
|
"""
|
|
Main rules engine for Magic: The Gathering.
|
|
|
|
Provides high-level rule enforcement by composing:
|
|
- CardParser for card validation
|
|
- ZoneManager for zone tracking
|
|
- CombatResolver for combat resolution
|
|
- KeywordValidator for keyword validation
|
|
|
|
Usage:
|
|
engine = RulesEngine()
|
|
result = engine.validate_action(card, player_state, action)
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.validator = KeywordValidator()
|
|
self.card_parser = CardParser()
|
|
self.zone_manager = ZoneManager()
|
|
self.combat_resolver = CombatResolver()
|
|
|
|
def validate_action(self, card: dict, player_state: dict, action: GameAction) -> dict:
|
|
"""
|
|
Validate a game action.
|
|
|
|
Args:
|
|
card: The validated card dictionary.
|
|
player_state: Dictionary with player's current state.
|
|
action: The GameAction to validate.
|
|
|
|
Returns:
|
|
Validation result dictionary.
|
|
"""
|
|
result = {
|
|
"valid": True,
|
|
"errors": [],
|
|
"warnings": [],
|
|
"action": action.action_type,
|
|
}
|
|
|
|
# Validate based on action type
|
|
if action.action_type == "cast":
|
|
result = self._validate_cast(card, player_state, result)
|
|
elif action.action_type == "attack":
|
|
result = self._validate_attack(card, player_state, result)
|
|
elif action.action_type == "block":
|
|
result = self._validate_block(card, player_state, result)
|
|
elif action.action_type == "resolve_combat":
|
|
result = self._validate_combat_resolution(player_state, result)
|
|
elif action.action_type == "zone_transition":
|
|
result = self._validate_zone_transition(card, player_state, result)
|
|
else:
|
|
result["valid"] = False
|
|
result["errors"].append(f"Unknown action type: {action.action_type}")
|
|
|
|
return result
|
|
|
|
def _validate_cast(self, card: dict, player_state: dict, result: dict) -> dict:
|
|
"""Validate a spell casting action."""
|
|
cast_result = self.card_parser.validate_casting(card, player_state)
|
|
|
|
if not cast_result["can_cast"]:
|
|
result["valid"] = False
|
|
result["errors"].extend(cast_result["errors"])
|
|
result["warnings"].extend(cast_result.get("warnings", []))
|
|
|
|
# Validate targets
|
|
if card.get("targets"):
|
|
for target in card["targets"]:
|
|
if not self.validator.is_valid_keyword(target):
|
|
result["valid"] = False
|
|
result["errors"].append(f"Invalid target keyword: {target}")
|
|
|
|
return result
|
|
|
|
def _validate_attack(self, card: dict, player_state: dict, result: dict) -> dict:
|
|
"""Validate an attack action."""
|
|
# Check if card is a creature
|
|
if card["type"] != "creature":
|
|
result["valid"] = False
|
|
result["errors"].append("Only creatures can attack")
|
|
return result
|
|
|
|
# Check summoning sickness
|
|
if not card.get("haste") and not card.get("untapped"):
|
|
result["valid"] = False
|
|
result["errors"].append("Creature has summoning sickness")
|
|
|
|
return result
|
|
|
|
def _validate_block(self, card: dict, player_state: dict, result: dict) -> dict:
|
|
"""Validate a block action."""
|
|
if card["type"] != "creature":
|
|
result["valid"] = False
|
|
result["errors"].append("Only creatures can block")
|
|
return result
|
|
|
|
# Check evasion abilities
|
|
attacker = player_state.get("attacker", {})
|
|
attacker_evasion = attacker.get("evasion", [])
|
|
blocker_evasion = card.get("evasion", [])
|
|
|
|
if not self.combat_resolver._blocker_can_block(blocker_evasion, attacker_evasion):
|
|
result["valid"] = False
|
|
result["errors"].append("Blocker can't block attacker due to evasion")
|
|
|
|
return result
|
|
|
|
def _validate_combat_resolution(self, player_state: dict, result: dict) -> dict:
|
|
"""Validate combat resolution."""
|
|
combat_result = self.combat_resolver.resolve_turn(player_state)
|
|
|
|
if combat_result.get("errors"):
|
|
result["valid"] = False
|
|
result["errors"].extend(combat_result["errors"])
|
|
|
|
return result
|
|
|
|
def _validate_zone_transition(self, card: dict, player_state: dict, result: dict) -> dict:
|
|
"""Validate a zone transition action."""
|
|
from_zone = player_state.get("from_zone")
|
|
to_zone = player_state.get("to_zone")
|
|
|
|
if not from_zone or not to_zone:
|
|
result["valid"] = False
|
|
result["errors"].append("Zone transition requires from_zone and to_zone")
|
|
return result
|
|
|
|
# Validate transition
|
|
valid_transitions = {
|
|
(Zone.HAND, Zone.BATTLEFIELD): CardTypeGroup.PERMANENT_SPELLS,
|
|
(Zone.LIBRARY, Zone.HAND): set(),
|
|
(Zone.HAND, Zone.LIBRARY): set(),
|
|
(Zone.BATTLEFIELD, Zone.GRAVEYARD): set(),
|
|
(Zone.GRAVEYARD, Zone.HAND): set(),
|
|
(Zone.BATTLEFIELD, Zone.EXILE): set(),
|
|
(Zone.EXILE, Zone.BATTLEFIELD): set(),
|
|
}
|
|
|
|
transition_key = (from_zone, to_zone)
|
|
if transition_key not in valid_transitions:
|
|
result["valid"] = False
|
|
result["errors"].append(f"Invalid zone transition: {from_zone} -> {to_zone}")
|
|
return result
|
|
|
|
return result
|
|
|
|
def get_rules_index(self) -> dict:
|
|
"""Get the rules index for reference."""
|
|
return RULES_INDEX
|
|
|
|
|
|
# =============================================================================
|
|
# GAME STATE MANAGER
|
|
# =============================================================================
|
|
|
|
class GameState:
|
|
"""
|
|
Manages the state of a Magic: The Gathering game.
|
|
|
|
Tracks:
|
|
- Players and their resources
|
|
- Card zones (hand, battlefield, graveyard, etc.)
|
|
- Stack of pending effects
|
|
- Current phase and turn
|
|
- Combat state
|
|
"""
|
|
|
|
def __init__(self, player_count: int = 2):
|
|
self.player_count = player_count
|
|
self.current_player = 0
|
|
self.current_phase = Phase.UNTAP
|
|
self.stack = []
|
|
self.zone_manager = ZoneManager()
|
|
self.combat_state = {
|
|
"attackers": [],
|
|
"blockers": [],
|
|
"damage_assigned": [],
|
|
"phase": None,
|
|
}
|
|
self.game_state = {
|
|
"players": self._init_players(),
|
|
"has_combat": False,
|
|
"defender_life": 20,
|
|
"targets": {},
|
|
}
|
|
|
|
def _init_players(self) -> list[dict]:
|
|
"""Initialize player states."""
|
|
players = []
|
|
for i in range(self.player_count):
|
|
players.append({
|
|
"id": i,
|
|
"life": 20,
|
|
"mana": 0,
|
|
"mana_available": 0,
|
|
"hand": [],
|
|
"zone": Zone.HAND,
|
|
})
|
|
return players
|
|
|
|
def advance_phase(self) -> str:
|
|
"""Advance to the next phase."""
|
|
phase_order = [
|
|
Phase.UNTAP,
|
|
Phase.DRAW,
|
|
Phase.MAIN_PHASE,
|
|
Phase.PRECOMBAT,
|
|
Phase.BEGINNING_COMBAT,
|
|
Phase.DECLARE_ATTACKERS,
|
|
Phase.DECLARE_BLOCKERS,
|
|
Phase.COMBAT_DAMAGE,
|
|
Phase.END_COMBAT,
|
|
Phase.END_PHASE,
|
|
Phase.END_STEP,
|
|
Phase.CLEANUP,
|
|
]
|
|
|
|
current_idx = phase_order.index(self.current_phase)
|
|
next_idx = (current_idx + 1) % len(phase_order)
|
|
self.current_phase = phase_order[next_idx]
|
|
return self.current_phase
|
|
|
|
def get_player_state(self, player_id: int) -> dict:
|
|
"""Get a player's state."""
|
|
return self.game_state["players"][player_id]
|
|
|
|
def set_combat_state(self, state: dict):
|
|
"""Set the combat phase state."""
|
|
self.combat_state.update(state)
|
|
self.game_state["has_combat"] = True
|
|
|
|
def get_engine(self) -> RulesEngine:
|
|
"""Get the rules engine for this game state."""
|
|
return RulesEngine()
|