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:
1 parent
731b40b15f
commit
de4f119d40
4 files changed
+275
-147
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user