Files
commander-ai-lab/src/commander_ai_lab/sim/models.py
T

455 lines
15 KiB
Python

"""
Commander AI Lab — Simulator Models
====================================
Data classes for Card, Player, PlayerResult, PlayerStats, SimState, and GameResult.
Ported from mtg-commander-lan JavaScript (dtCreatePlayer, dtSimGame structs).
N-player ready: GameResult.players is a list[PlayerResult] (no hardcoded seat count).
SimState.battlefields is per-player: battlefields[seat_index] holds that player's permanents.
"""
from __future__ import annotations
import copy
import enum
import json
import re
from dataclasses import dataclass, field
from typing import Optional
# ── Turn Phases ──────────────────────────────────────────────
class Phase(str, enum.Enum):
"""MTG turn phases in order.
Using ``str`` as a mixin allows direct comparison with plain strings
and clean JSON serialization (``phase.value`` is a string).
"""
UNTAP = "untap"
UPKEEP = "upkeep"
DRAW = "draw"
MAIN1 = "main1"
BEGIN_COMBAT = "begin_combat"
DECLARE_ATTACKERS = "declare_attackers"
DECLARE_BLOCKERS = "declare_blockers"
COMBAT_DAMAGE = "combat_damage"
END_COMBAT = "end_combat"
MAIN2 = "main2"
END_STEP = "end_step"
CLEANUP = "cleanup"
# Ordered tuple for iteration
PHASE_ORDER: tuple[Phase, ...] = tuple(Phase)
# Phases where sorcery-speed actions (land drops, casting creatures) are legal
SORCERY_PHASES: frozenset[Phase] = frozenset({Phase.MAIN1, Phase.MAIN2})
# ── Combat State ───────────────────────────────────────────
@dataclass
class CombatState:
"""Transient combat state shared across combat sub-phases.
Created at BEGIN_COMBAT, cleared to None at END_COMBAT.
Allows priority windows between attacker declaration, blocker
declaration, and damage resolution (Issue #86 Item 1).
"""
# Seat index of the defending player (multiplayer: chosen target)
defending_seat: int = -1
# attacker card_id -> defending seat
attackers: dict[int, int] = field(default_factory=dict)
# attacker card_id -> list of blocker card_ids
blockers: dict[int, list[int]] = field(default_factory=dict)
# Damage routed to the defending player per-attacker (for unblocked / trample)
player_damage: dict[int, int] = field(default_factory=dict)
# Whether the first-strike damage sub-step has already resolved
first_strike_resolved: bool = False
# Whether combat is currently active
active: bool = False
@dataclass
class Card:
"""A single card in the simulation."""
name: str = ""
type_line: str = ""
oracle_text: str = ""
cmc: int = 0
pt: str = "" # e.g. "3/4"
mana_cost: str = ""
power: str = ""
toughness: str = ""
color_identity: list[str] = field(default_factory=list)
keywords: list[str] = field(default_factory=list)
# Functional flags (set by enrich_card)
is_ramp: bool = False
is_removal: bool = False
is_board_wipe: bool = False
is_commander: bool = False
is_direct_damage: bool = False
direct_damage_amount: int = 0
# Forge enrichment fields (set by forge_card_loader)
forge_enriched: bool = False
forge_keywords: list[str] = field(default_factory=list)
forge_trigger_modes: list[str] = field(default_factory=list)
has_replacement_effect: bool = False
has_static_ability: bool = False
# Runtime fields (set during game)
id: int = 0
owner_id: int = -1
tapped: bool = False
turn_played: int = -1
damage_marked: int = 0 # track damage marked on creature (cleared each cleanup step)
def get_power(self) -> int:
"""Extract numeric power from pt string."""
if self.power:
try:
return int(self.power)
except ValueError:
return 0
if not self.pt:
return 0
parts = self.pt.split("/")
try:
return int(parts[0])
except (ValueError, IndexError):
return 0
def get_toughness(self) -> int:
"""Extract numeric toughness from pt string."""
if self.toughness:
try:
return int(self.toughness)
except ValueError:
return 0
if not self.pt:
return 0
parts = self.pt.split("/")
try:
return int(parts[1])
except (ValueError, IndexError):
return 0
def has_keyword(self, kw: str) -> bool:
"""Check if card has a keyword (checks keywords list and oracle text)."""
kw_lower = kw.lower()
if self.keywords and any(k.lower() == kw_lower for k in self.keywords):
return True
# Bug 15 fix: word-boundary match prevents false positives
# (e.g. "reach" matching "breach", "ward" matching "reward")
return bool(re.search(r'\b' + re.escape(kw_lower) + r'\b', (self.oracle_text or "").lower()))
def is_land(self) -> bool:
return bool(self.type_line and "land" in self.type_line.lower())
def is_creature(self) -> bool:
return bool(self.type_line and "creature" in self.type_line.lower())
def clone(self) -> "Card":
"""Return a deep copy of this card."""
return copy.deepcopy(self)
@dataclass
class PlayerStats:
"""Per-game statistics tracked for a player."""
cards_drawn: int = 7 # starting hand
mulligans: int = 0
opening_hand_lands: int = 0
lands_played: int = 0
spells_cast: int = 0
creatures_played: int = 0
removal_used: int = 0
board_wipes_used: int = 0
ramp_played: int = 0
damage_dealt: int = 0
damage_received: int = 0
max_board_size: int = 0
turns_alive: int = 0
mana_spent: int = 0
def to_dict(self) -> dict:
return {
"cardsDrawn": self.cards_drawn,
"mulligans": self.mulligans,
"openingHandLands": self.opening_hand_lands,
"landsPlayed": self.lands_played,
"spellsCast": self.spells_cast,
"creaturesPlayed": self.creatures_played,
"removalUsed": self.removal_used,
"boardWipesUsed": self.board_wipes_used,
"rampPlayed": self.ramp_played,
"damageDealt": self.damage_dealt,
"damageReceived": self.damage_received,
"maxBoardSize": self.max_board_size,
"turnsAlive": self.turns_alive,
"manaSpent": self.mana_spent,
}
@dataclass
class Player:
"""A player in the headless simulation."""
name: str = ""
life: int = 40
eliminated: bool = False
owner_id: int = 0
# Zones
library: list[Card] = field(default_factory=list)
hand: list[Card] = field(default_factory=list)
graveyard: list[Card] = field(default_factory=list)
exile: list[Card] = field(default_factory=list)
command_zone: list[Card] = field(default_factory=list)
# commander_tax tracks the cumulative tax per commander name.
# Keyed by card name so N-player pods with multiple commanders
# each track their own tax independently.
# engine._play_spells uses: tax = p.commander_tax.get(name, 0)
# p.commander_tax[name] = tax + 2
commander_tax: dict = field(default_factory=dict)
# Commander damage received from each opponent, keyed by seat index.
# This is the aggregate per-seat total (used by GUI matrix and prompt
# builder). For games where a player controls partner commanders
# the per-card breakdown lives in commander_damage_by_card.
commander_damage_received: dict[int, int] = field(default_factory=dict)
# Per-commander breakdown: {(seat, card_name): damage}.
# In MTG rules, lethal commander damage (21+) is tracked per individual
# commander card, not per player. This dict captures that.
commander_damage_by_card: dict[tuple[int, str], int] = field(default_factory=dict)
stats: PlayerStats = field(default_factory=PlayerStats)
def is_dead_to_commander_damage(self) -> bool:
"""Returns True if any single commander has dealt 21+ damage.
Checks per-card tracking first (partner-correct); falls back to
per-seat totals for backward compatibility with older game states.
"""
if self.commander_damage_by_card:
return any(v >= 21 for v in self.commander_damage_by_card.values())
return any(v >= 21 for v in self.commander_damage_received.values())
@dataclass
class SimState:
"""Full simulation state for a headless game.
battlefields is a list-of-lists: battlefields[seat_index] holds
the permanents controlled by that player. Legacy single-list code
should use the helpers get_battlefield() / all_battlefield_cards().
"""
players: list[Player] = field(default_factory=list)
battlefields: list[list[Card]] = field(default_factory=list)
turn: int = 0
max_turns: int = 25
next_card_id: int = 90000
current_phase: Phase = Phase.MAIN1
active_player_index: int = 0
# Combat state (transient, set during combat phases)
combat: Optional[CombatState] = None
# ── Battlefield helpers ──────────────────────────────────
def get_battlefield(self, seat: int) -> list[Card]:
"""Return the battlefield list for a given seat index."""
while len(self.battlefields) <= seat:
self.battlefields.append([])
return self.battlefields[seat]
def all_battlefield_cards(self) -> list[Card]:
"""Return a flat list of every card on every player's battlefield."""
cards: list[Card] = []
for bf in self.battlefields:
cards.extend(bf)
return cards
def add_to_battlefield(self, seat: int, card: Card) -> None:
"""Place a card onto the given player's battlefield."""
while len(self.battlefields) <= seat:
self.battlefields.append([])
self.battlefields[seat].append(card)
def remove_from_battlefield(self, card_id: int) -> Optional[Card]:
"""Remove a card by id from any player's battlefield. Returns the card or None."""
for bf in self.battlefields:
for i, c in enumerate(bf):
if c.id == card_id:
return bf.pop(i)
return None
def filter_battlefield(self, seat: int, predicate) -> list[Card]:
"""Remove cards from seat's battlefield that don't match predicate, return removed."""
bf = self.get_battlefield(seat)
keep = []
removed = []
for c in bf:
if predicate(c):
keep.append(c)
else:
removed.append(c)
self.battlefields[seat] = keep
return removed
def init_battlefields(self, num_players: int) -> None:
"""Ensure battlefields list has one empty list per player."""
while len(self.battlefields) < num_players:
self.battlefields.append([])
# ══════════════════════════════════════════════════════════════
# Game Results — N-player ready
# ══════════════════════════════════════════════════════════════
@dataclass
class PlayerResult:
"""Post-game result snapshot for one player.
Matches the Java BatchResult.PlayerResult schema structure.
"""
seat_index: int = 0
name: str = ""
life: int = 0
eliminated: bool = False
finish_position: int = 0 # 1 = winner, 2+ = elimination order
stats: Optional[PlayerStats] = None
def to_dict(self) -> dict:
return {
"seatIndex": self.seat_index,
"name": self.name,
"finalLife": self.life,
"eliminated": self.eliminated,
"finishPosition": self.finish_position,
"isWinner": self.finish_position == 1,
"stats": self.stats.to_dict() if self.stats else {},
}
@dataclass
class GameResult:
"""Result of a single simulated game (N-player ready).
- players: list of PlayerResult, one per seat
- winner_seat: index of the winning player (-1 = draw)
- Backward-compat properties player_a_stats / player_b_stats delegate to players[0] / players[1]
"""
winner_seat: int = -1
turns: int = 0
players: list[PlayerResult] = field(default_factory=list)
game_log: list = field(default_factory=list)
# ── Convenience accessors ────────────────────────────────
def player(self, seat: int) -> Optional[PlayerResult]:
"""Safe accessor for a seat's result."""
if 0 <= seat < len(self.players):
return self.players[seat]
return None
# Backward-compat properties so existing code referencing
# result.player_a_stats or result.winner keeps working.
@property
def winner(self) -> int:
return self.winner_seat
@property
def player_a_stats(self) -> Optional[PlayerStats]:
p = self.player(0)
return p.stats if p else None
@property
def player_b_stats(self) -> Optional[PlayerStats]:
p = self.player(1)
return p.stats if p else None
@property
def player_a_name(self) -> str:
p = self.player(0)
return p.name if p else ""
@property
def player_b_name(self) -> str:
p = self.player(1)
return p.name if p else ""
@property
def player_a_life(self) -> int:
p = self.player(0)
return p.life if p else 0
@property
def player_b_life(self) -> int:
p = self.player(1)
return p.life if p else 0
@property
def player_a_eliminated(self) -> bool:
p = self.player(0)
return p.eliminated if p else False
@property
def player_b_eliminated(self) -> bool:
p = self.player(1)
return p.eliminated if p else False
def to_dict(self) -> dict:
"""Serialize to dict.
Returns both the legacy 2-player keys (winner, playerA, playerB)
that the existing UI and lab_api consumers expect AND the new
N-player 'playerResults' array for the Java BatchResult schema.
"""
d: dict = {
# Legacy keys (UI expects these)
"winner": self.winner_seat,
"turns": self.turns,
# New N-player keys (Java BatchResult schema)
"winningSeat": self.winner_seat,
"playerResults": [p.to_dict() for p in self.players],
}
# Legacy playerA / playerB dicts for the UI
pa = self.player(0)
pb = self.player(1)
d["playerA"] = {
"name": pa.name if pa else "",
"life": pa.life if pa else 0,
"eliminated": pa.eliminated if pa else False,
"stats": pa.stats.to_dict() if pa and pa.stats else {},
}
d["playerB"] = {
"name": pb.name if pb else "",
"life": pb.life if pb else 0,
"eliminated": pb.eliminated if pb else False,
"stats": pb.stats.to_dict() if pb and pb.stats else {},
}
if self.game_log:
d["gameLog"] = self.game_log
return d