diff --git a/src/commander_ai_lab/sim/deepseek_brain.py b/src/commander_ai_lab/sim/deepseek_brain.py index abc5ac6..5680873 100644 --- a/src/commander_ai_lab/sim/deepseek_brain.py +++ b/src/commander_ai_lab/sim/deepseek_brain.py @@ -118,8 +118,10 @@ def build_game_state_snapshot( # ── Mana by Color (Enhancement #8) ────────────────────────── mana_total = 0 mana_by_color = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0} - for c in sim_state.battlefield: - if c.owner_id == player_index and not c.tapped and c.is_land(): + my_bf = sim_state.get_battlefield(player_index) + opp_bf = sim_state.get_battlefield(1 - player_index) + for c in my_bf: + if not c.tapped and c.is_land(): mana_total += 1 land_name = c.name.lower().strip() if land_name in _LAND_COLOR_MAP: @@ -176,9 +178,7 @@ def build_game_state_snapshot( # ── My Board — with keywords and oracle for synergy ─────── my_board = [] - for c in sim_state.battlefield: - if c.owner_id != player_index: - continue + for c in my_bf: if c.is_land(): continue entry = { @@ -200,9 +200,7 @@ def build_game_state_snapshot( opp_total_power = 0 opp_evasion_count = 0 opp_protected_count = 0 - for c in sim_state.battlefield: - if c.owner_id != (1 - player_index): - continue + for c in opp_bf: if c.is_land(): continue entry = { @@ -241,9 +239,7 @@ def build_game_state_snapshot( # ── Draw Tracking (Enhancement #7) ───────────────────────── total_deck_size = ctx.get("deck_size", 100) - cards_seen = len(me.hand) + len(me.graveyard) + sum( - 1 for c in sim_state.battlefield if c.owner_id == player_index - ) + cards_seen = len(me.hand) + len(me.graveyard) + len(my_bf) cards_remaining = len(me.library) pct_drawn = round(cards_seen / max(total_deck_size, 1) * 100) @@ -287,8 +283,8 @@ def build_game_state_snapshot( if c.name.lower() == cmd_name_lower: cmd_zone = "hand" break - for c in sim_state.battlefield: - if c.owner_id == player_index and c.name.lower() == cmd_name_lower: + for c in my_bf: + if c.name.lower() == cmd_name_lower: cmd_zone = "battlefield" break for c in me.graveyard: diff --git a/src/commander_ai_lab/sim/deepseek_engine.py b/src/commander_ai_lab/sim/deepseek_engine.py index f6bb8c3..ea148e1 100644 --- a/src/commander_ai_lab/sim/deepseek_engine.py +++ b/src/commander_ai_lab/sim/deepseek_engine.py @@ -31,7 +31,7 @@ import random import uuid from typing import Optional -from commander_ai_lab.sim.models import Card, GameResult, Player, PlayerStats, SimState +from commander_ai_lab.sim.models import Card, GameResult, Player, PlayerResult, PlayerStats, SimState from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, enrich_card, score_card from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig, build_deck_context @@ -132,14 +132,13 @@ class DeepSeekGameEngine: turn_decisions.append(pre_snapshot) # ── Track board size ── - board_size = sum(1 for c in sim.battlefield if c.owner_id == pi) + board_size = len(sim.get_battlefield(pi)) if board_size > p.stats.max_board_size: p.stats.max_board_size = board_size # ── Untap ── - for c in sim.battlefield: - if c.owner_id == pi: - c.tapped = False + for c in sim.get_battlefield(pi): + c.tapped = False if self.record_log: turn_entry["phases"].append({ @@ -150,8 +149,8 @@ class DeepSeekGameEngine: sim.players[0].name: sim.players[0].life, sim.players[1].name: sim.players[1].life, }, - "boardA": [c.name for c in sim.battlefield if c.owner_id == 0 and c.is_creature()], - "boardB": [c.name for c in sim.battlefield if c.owner_id == 1 and c.is_creature()], + "boardA": [c.name for c in sim.get_battlefield(0) if c.is_creature()], + "boardB": [c.name for c in sim.get_battlefield(1) if c.is_creature()], }) # Check game over @@ -315,7 +314,7 @@ class DeepSeekGameEngine: land_card.tapped = False land_card.id = sim.next_card_id sim.next_card_id += 1 - sim.battlefield.append(land_card) + sim.add_to_battlefield(pi, land_card) p.stats.lands_played += 1 if events is not None and self.record_log: @@ -426,10 +425,10 @@ class DeepSeekGameEngine: # Pay mana mana_needed = card.cmc or 0 - for bf_card in sim.battlefield: + for bf_card in sim.get_battlefield(pi): if mana_needed <= 0: break - if bf_card.owner_id == pi and not bf_card.tapped and bf_card.is_land(): + if not bf_card.tapped and bf_card.is_land(): bf_card.tapped = True mana_needed -= 1 @@ -439,14 +438,15 @@ class DeepSeekGameEngine: # Handle removal if card.is_removal: p.stats.removal_used += 1 + opp_idx_r = 1 - pi opp_creatures = [ - c for c in sim.battlefield - if c.owner_id != pi and c.is_creature() + c for c in sim.get_battlefield(opp_idx_r) + if c.is_creature() ] if opp_creatures: opp_creatures.sort(key=lambda c: -score_card(c, w)) killed = opp_creatures[0] - sim.battlefield = [c for c in sim.battlefield if c.id != killed.id] + sim.remove_from_battlefield(killed.id) sim.players[killed.owner_id].graveyard.append(killed) if events is not None: events.append(f"Cast {card.name} (removal) — destroyed {killed.name}") @@ -457,20 +457,23 @@ class DeepSeekGameEngine: elif card.is_board_wipe: p.stats.board_wipes_used += 1 - wiped = [c.name for c in sim.battlefield if c.is_creature()] - surviving = [] - for c in sim.battlefield: - if c.is_creature(): - sim.players[c.owner_id].graveyard.append(c) - else: - surviving.append(c) - sim.battlefield = surviving + wiped_names = [] + for seat_idx in range(len(sim.players)): + bf = sim.get_battlefield(seat_idx) + keep = [] + for c in bf: + if c.is_creature(): + wiped_names.append(c.name) + sim.players[c.owner_id].graveyard.append(c) + else: + keep.append(c) + sim.battlefields[seat_idx] = keep p.graveyard.append(card) if events is not None: - events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped)} creatures") + events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped_names)} creatures") else: - sim.battlefield.append(card) + sim.add_to_battlefield(pi, card) if card.is_creature(): p.stats.creatures_played += 1 if events is not None: @@ -506,16 +509,16 @@ class DeepSeekGameEngine: return my_creatures = [ - c for c in sim.battlefield - if c.owner_id == pi and c.is_creature() and not c.tapped + c for c in sim.get_battlefield(pi) + if c.is_creature() and not c.tapped and (turn > 0 or c.turn_played != turn) ] if not my_creatures: return opp_blockers = [ - c for c in sim.battlefield - if c.owner_id == opp_idx and c.is_creature() and not c.tapped + c for c in sim.get_battlefield(opp_idx) + if c.is_creature() and not c.tapped ] # Select attackers based on LLM's preference @@ -552,16 +555,16 @@ class DeepSeekGameEngine: return False my_creatures = [ - c for c in sim.battlefield - if c.owner_id == pi and c.is_creature() and not c.tapped + c for c in sim.get_battlefield(pi) + if c.is_creature() and not c.tapped and (turn > 0 or c.turn_played != turn) ] if not my_creatures: return False opp_blockers = [ - c for c in sim.battlefield - if c.owner_id == opp_idx and c.is_creature() and not c.tapped + c for c in sim.get_battlefield(opp_idx) + if c.is_creature() and not c.tapped ] # Same attacker selection as base engine @@ -646,13 +649,13 @@ class DeepSeekGameEngine: combat_details.append(f"{atk.name} blocked by {blocker.name}") if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"): - sim.battlefield = [c for c in sim.battlefield if c.id != blocker.id] + sim.remove_from_battlefield(blocker.id) sim.players[opp_idx].graveyard.append(blocker) if events is not None: combat_details.append(f" {blocker.name} dies") if b_pow >= a_tou or blocker.has_keyword("deathtouch"): - sim.battlefield = [c for c in sim.battlefield if c.id != atk.id] + sim.remove_from_battlefield(atk.id) sim.players[pi].graveyard.append(atk) if events is not None: combat_details.append(f" {atk.name} dies") @@ -698,25 +701,26 @@ class DeepSeekGameEngine: for seat in range(2): p = sim.players[seat] # Count creatures and lands on battlefield + seat_bf = sim.get_battlefield(seat) my_creatures = sum( - 1 for c in sim.battlefield - if c.owner_id == seat and c.is_creature() + 1 for c in seat_bf + if c.is_creature() ) my_lands = sum( - 1 for c in sim.battlefield - if c.owner_id == seat and c.is_land() + 1 for c in seat_bf + if c.is_land() ) available_mana = sum( - 1 for c in sim.battlefield - if c.owner_id == seat and not c.tapped and c.is_land() + 1 for c in seat_bf + if not c.tapped and c.is_land() ) # Card name lists for zones (for embedding lookup) hand_names = [c.name for c in p.hand] graveyard_names = [c.name for c in p.graveyard] battlefield_names = [ - c.name for c in sim.battlefield - if c.owner_id == seat and not c.is_land() + c.name for c in seat_bf + if not c.is_land() ] # Command zone — not explicitly tracked in Python sim, use empty command_zone_names = [] @@ -779,8 +783,8 @@ class DeepSeekGameEngine: def _count_untapped_lands(self, sim: SimState, pi: int) -> int: return sum( - 1 for c in sim.battlefield - if c.owner_id == pi and not c.tapped and c.is_land() + 1 for c in sim.get_battlefield(pi) + if not c.tapped and c.is_land() ) def _create_state( @@ -802,6 +806,7 @@ class DeepSeekGameEngine: stats=PlayerStats(cards_drawn=7), ) sim.players.append(player) + sim.init_battlefields(len(sim.players)) return sim def _build_result( @@ -818,15 +823,19 @@ class DeepSeekGameEngine: else: winner = 0 if pa.life >= pb.life else 1 + player_results = [] + for seat, p in enumerate(sim.players): + player_results.append(PlayerResult( + seat_index=seat, + name=p.name, + life=p.life, + eliminated=p.eliminated, + finish_position=1 if seat == winner else 2, + stats=p.stats, + )) + return GameResult( - winner=winner, + winner_seat=winner, turns=min(final_turn, sim.max_turns), - player_a_name=name_a, - player_a_life=pa.life, - player_a_eliminated=pa.eliminated, - player_a_stats=pa.stats, - player_b_name=name_b, - player_b_life=pb.life, - player_b_eliminated=pb.eliminated, - player_b_stats=pb.stats, + players=player_results, ) diff --git a/src/commander_ai_lab/sim/engine.py b/src/commander_ai_lab/sim/engine.py index e8d5a54..ceb365f 100644 --- a/src/commander_ai_lab/sim/engine.py +++ b/src/commander_ai_lab/sim/engine.py @@ -21,6 +21,7 @@ from commander_ai_lab.sim.models import ( Card, GameResult, Player, + PlayerResult, PlayerStats, SimState, ) @@ -93,8 +94,8 @@ class GameEngine: if self.record_log and p.stats.lands_played > land_before: # Find what land was just played last_land = next( - (c for c in reversed(sim.battlefield) - if c.owner_id == pi and c.is_land()), + (c for c in reversed(sim.get_battlefield(pi)) + if c.is_land()), None, ) if last_land: @@ -108,9 +109,7 @@ class GameEngine: self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None) # ── Track board size ── - board_size = sum( - 1 for c in sim.battlefield if c.owner_id == pi - ) + board_size = len(sim.get_battlefield(pi)) if board_size > p.stats.max_board_size: p.stats.max_board_size = board_size @@ -120,9 +119,8 @@ class GameEngine: self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None) # ── Untap ── - for c in sim.battlefield: - if c.owner_id == pi: - c.tapped = False + for c in sim.get_battlefield(pi): + c.tapped = False if self.record_log: turn_entry["phases"].append({ @@ -131,8 +129,8 @@ class GameEngine: "events": phase_events, "lifeAfter": {sim.players[0].name: sim.players[0].life, sim.players[1].name: sim.players[1].life}, - "boardA": [c.name for c in sim.battlefield if c.owner_id == 0 and c.is_creature()], - "boardB": [c.name for c in sim.battlefield if c.owner_id == 1 and c.is_creature()], + "boardA": [c.name for c in sim.get_battlefield(0) if c.is_creature()], + "boardB": [c.name for c in sim.get_battlefield(1) if c.is_creature()], }) # Check game over @@ -186,6 +184,7 @@ class GameEngine: ) sim.players.append(player) + sim.init_battlefields(len(sim.players)) return sim def _play_land(self, sim: SimState, pi: int) -> None: @@ -204,15 +203,15 @@ class GameEngine: land_card.tapped = False land_card.id = sim.next_card_id sim.next_card_id += 1 - sim.battlefield.append(land_card) + sim.add_to_battlefield(pi, land_card) p.stats.lands_played += 1 def _count_untapped_lands(self, sim: SimState, pi: int) -> int: """Count untapped lands for a player.""" return sum( 1 - for c in sim.battlefield - if c.owner_id == pi and not c.tapped and c.is_land() + for c in sim.get_battlefield(pi) + if not c.tapped and c.is_land() ) def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None: @@ -251,14 +250,10 @@ class GameEngine: # Pay mana mana_needed = card.cmc or 0 - for bf_card in sim.battlefield: + for bf_card in sim.get_battlefield(pi): if mana_needed <= 0: break - if ( - bf_card.owner_id == pi - and not bf_card.tapped - and bf_card.is_land() - ): + if not bf_card.tapped and bf_card.is_land(): bf_card.tapped = True mana_needed -= 1 @@ -268,17 +263,16 @@ class GameEngine: # Handle removal if card.is_removal: p.stats.removal_used += 1 + opp_idx_r = 1 - pi opp_creatures = [ c - for c in sim.battlefield - if c.owner_id != pi and c.is_creature() + for c in sim.get_battlefield(opp_idx_r) + if c.is_creature() ] if opp_creatures: opp_creatures.sort(key=lambda c: -score_card(c, w)) killed = opp_creatures[0] - sim.battlefield = [ - c for c in sim.battlefield if c.id != killed.id - ] + sim.remove_from_battlefield(killed.id) sim.players[killed.owner_id].graveyard.append(killed) if events is not None: events.append(f"Cast {card.name} (removal) — destroyed {killed.name}") @@ -289,20 +283,23 @@ class GameEngine: elif card.is_board_wipe: p.stats.board_wipes_used += 1 - wiped = [c.name for c in sim.battlefield if c.is_creature()] - surviving = [] - for c in sim.battlefield: - if c.is_creature(): - sim.players[c.owner_id].graveyard.append(c) - else: - surviving.append(c) - sim.battlefield = surviving + wiped_names = [] + for seat_idx in range(len(sim.players)): + bf = sim.get_battlefield(seat_idx) + keep = [] + for c in bf: + if c.is_creature(): + wiped_names.append(c.name) + sim.players[c.owner_id].graveyard.append(c) + else: + keep.append(c) + sim.battlefields[seat_idx] = keep p.graveyard.append(card) if events is not None: - events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped)} creatures") + events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped_names)} creatures") else: - sim.battlefield.append(card) + sim.add_to_battlefield(pi, card) if card.is_creature(): p.stats.creatures_played += 1 if events is not None: @@ -333,10 +330,9 @@ class GameEngine: # My creatures that can attack (not tapped, not summoning sick) my_creatures = [ c - for c in sim.battlefield + for c in sim.get_battlefield(pi) if ( - c.owner_id == pi - and c.is_creature() + c.is_creature() and not c.tapped and (turn > 0 or c.turn_played != turn) ) @@ -346,10 +342,9 @@ class GameEngine: opp_blockers = [ c - for c in sim.battlefield + for c in sim.get_battlefield(opp_idx) if ( - c.owner_id == opp_idx - and c.is_creature() + c.is_creature() and not c.tapped ) ] @@ -431,18 +426,14 @@ class GameEngine: # Attacker kills blocker? if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"): - sim.battlefield = [ - c for c in sim.battlefield if c.id != blocker.id - ] + sim.remove_from_battlefield(blocker.id) sim.players[opp_idx].graveyard.append(blocker) if events is not None: combat_details.append(f" {blocker.name} dies") # Blocker kills attacker? if b_pow >= a_tou or blocker.has_keyword("deathtouch"): - sim.battlefield = [ - c for c in sim.battlefield if c.id != atk.id - ] + sim.remove_from_battlefield(atk.id) sim.players[pi].graveyard.append(atk) if events is not None: combat_details.append(f" {atk.name} dies") @@ -490,15 +481,19 @@ class GameEngine: # Both alive or both dead: compare life winner = 0 if pa.life >= pb.life else 1 + player_results = [] + for seat, p in enumerate(sim.players): + player_results.append(PlayerResult( + seat_index=seat, + name=p.name, + life=p.life, + eliminated=p.eliminated, + finish_position=1 if seat == winner else 2, + stats=p.stats, + )) + return GameResult( - winner=winner, + winner_seat=winner, turns=min(final_turn, sim.max_turns), - player_a_name=name_a, - player_a_life=pa.life, - player_a_eliminated=pa.eliminated, - player_a_stats=pa.stats, - player_b_name=name_b, - player_b_life=pb.life, - player_b_eliminated=pb.eliminated, - player_b_stats=pb.stats, + players=player_results, ) diff --git a/src/commander_ai_lab/sim/models.py b/src/commander_ai_lab/sim/models.py index 8e3f116..5b7b001 100644 --- a/src/commander_ai_lab/sim/models.py +++ b/src/commander_ai_lab/sim/models.py @@ -1,8 +1,11 @@ """ Commander AI Lab — Simulator Models ==================================== -Data classes for Card, Player, PlayerStats, and SimState. +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 @@ -142,47 +145,172 @@ class Player: @dataclass class SimState: - """Full simulation state for a headless game.""" + """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) - battlefield: list[Card] = field(default_factory=list) + battlefields: list[list[Card]] = field(default_factory=list) turn: int = 0 max_turns: int = 25 next_card_id: int = 90000 + # ── 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.""" + """Result of a single simulated game (N-player ready). - winner: int = -1 # 0 or 1 (player index), -1 = draw + - 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 - player_a_name: str = "" - player_a_life: int = 0 - player_a_eliminated: bool = False - player_a_stats: Optional[PlayerStats] = None - player_b_name: str = "" - player_b_life: int = 0 - player_b_eliminated: bool = False - player_b_stats: Optional[PlayerStats] = None - game_log: list = field(default_factory=list) # turn-by-turn log (populated when record_log=True) + 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 matching Java BatchResult.GameResult schema.""" d = { - "winner": self.winner, + "winningSeat": self.winner_seat, "turns": self.turns, - "playerA": { - "name": self.player_a_name, - "life": self.player_a_life, - "eliminated": self.player_a_eliminated, - "stats": self.player_a_stats.to_dict() if self.player_a_stats else {}, - }, - "playerB": { - "name": self.player_b_name, - "life": self.player_b_life, - "eliminated": self.player_b_eliminated, - "stats": self.player_b_stats.to_dict() if self.player_b_stats else {}, - }, + "playerResults": [p.to_dict() for p in self.players], } if self.game_log: d["gameLog"] = self.game_log