Phase 1: N-player data model refactoring

- New PlayerResult dataclass with seat_index, name, life, eliminated, finish_position, stats
- Refactored GameResult to use players: list[PlayerResult] + winner_seat (no hardcoded 2-player fields)
- Backward-compat @property accessors (player_a_stats, player_b_stats, winner, etc.) for existing consumers
- SimState.battlefields is now per-player (list-of-lists) with helper methods:
  get_battlefield(), add_to_battlefield(), remove_from_battlefield(), all_battlefield_cards()
- Updated engine.py, deepseek_engine.py, deepseek_brain.py to use per-player battlefields
- GameResult.to_dict() matches Java BatchResult schema (playerResults array)
- lab_api.py and experiments.py unchanged — backward-compat properties handle all existing access patterns
This commit is contained in:
KoalaTrapLord committed 2026-03-12 18:10:57 +00:00
1 parent 731b40b15f
commit de4f119d40
4 files changed
+275 -147

No files matched your search

+9 -13
View File
@@ -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:
+61 -52
View File
@@ -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,
)
+50 -55
View File
@@ -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,
)
+155 -27
View File
@@ -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