Files
mtgonline/backend/mtg_rules_engine/rules_engine.py
T

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()