Files
commander-ai-lab/src/commander_ai_lab/sim/engine.py
T
KoalaTrapLord 0be8841cec feat: N-player engine turn loop (Task 2A — Issue #27)
- engine.py: new run_n() method accepts 2-4 decks; run() wraps for backward compat
- _select_attack_target() heuristic: attack weakest non-eliminated opponent
- N-player elimination order tracking with correct finish positions
- Removal spells scan all opponents for best target
- Game logs use per-seat boards dict with boardA/boardB legacy keys for 2-player
- Tested: 2/3/4-player, elimination order, attack targeting
2026-03-13 16:54:55 -04:00

559 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Commander AI Lab — Headless Game Engine
=========================================
A headless Commander game simulator for Monte Carlo analysis.
Faithful port of dtSimGame() / dtCreatePlayer() from mtg-commander-lan.
N-player ready: accepts 2–4 decks and runs a full multiplayer game.
Simulates simplified Commander games:
- Draw, land drop, spell casting (up to 2 per turn)
- AI card scoring for play priority
- Simplified combat with flying, trample, deathtouch, lifelink, menace, reach
- Multiplayer attack targeting: weakest non-eliminated opponent heuristic
- Creature removal and board wipe handling
- Win by elimination (life <= 0) or life comparison at max turns
- Elimination order tracking for N-player finish positions
"""
from __future__ import annotations
import random
from typing import Optional
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,
)
class GameEngine:
"""
Headless Commander game engine (N-player ready).
Usage::
engine = GameEngine(max_turns=25)
# 2-player (backward-compatible)
result = engine.run(deck_a, deck_b, name_a="Deck A", name_b="Deck B")
# 4-player
result = engine.run_n(decks=[d1, d2, d3, d4], names=["A","B","C","D"])
"""
def __init__(
self,
max_turns: int = 25,
starting_life: int = 40,
weights: Optional[dict] = None,
record_log: bool = False,
):
self.max_turns = max_turns
self.starting_life = starting_life
self.weights = weights or AI_DEFAULT_WEIGHTS
self.record_log = record_log
# ──────────────────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────────────────
def run(
self,
deck_a: list[Card],
deck_b: list[Card],
name_a: str = "Player A",
name_b: str = "Player B",
) -> GameResult:
"""Backward-compatible 2-player entry point."""
return self.run_n(
decks=[deck_a, deck_b],
names=[name_a, name_b],
)
def run_n(
self,
decks: list[list[Card]],
names: list[str] | None = None,
) -> GameResult:
"""Run a single headless N-player game and return the result."""
n = len(decks)
if names is None:
names = [f"Player {chr(65 + i)}" for i in range(n)]
sim = self._create_state(decks, names)
game_log: list[dict] = []
elimination_order: list[int] = [] # seats in order of elimination
final_turn = 0
for turn in range(sim.max_turns):
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(len(sim.players)):
p = sim.players[pi]
if p.eliminated:
continue
p.stats.turns_alive += 1
phase_events: list[str] = []
# ── Draw ──
if p.library:
drawn = p.library[-1] # peek before pop
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
phase_events.append(f"Drew {drawn.name}")
# ── Land drop ──
land_before = p.stats.lands_played
self._play_land(sim, pi)
if self.record_log and p.stats.lands_played > land_before:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
phase_events.append(f"Played land: {last_land.name}")
# ── Available mana ──
available_mana = self._count_untapped_lands(sim, pi)
# ── Play spells (up to 2) ──
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
# ── Track board size ──
board_size = len(sim.get_battlefield(pi))
if board_size > p.stats.max_board_size:
p.stats.max_board_size = board_size
# ── Combat ──
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
# ── Untap ──
for c in sim.get_battlefield(pi):
c.tapped = False
if self.record_log:
# N-player log: life dict + per-seat boards dict
life_dict = {sp.name: sp.life for sp in sim.players}
boards_dict = {
f"board_{si}": [c.name for c in sim.get_battlefield(si) if c.is_creature()]
for si in range(len(sim.players))
}
phase_entry: dict = {
"player": p.name,
"playerId": pi,
"events": phase_events,
"lifeAfter": life_dict,
**boards_dict,
}
# Legacy keys for 2-player UI compatibility
if len(sim.players) == 2:
phase_entry["boardA"] = boards_dict.get("board_0", [])
phase_entry["boardB"] = boards_dict.get("board_1", [])
turn_entry["phases"].append(phase_entry)
# ── Check for new eliminations after this player's turn ──
for si, sp in enumerate(sim.players):
if sp.eliminated and si not in elimination_order:
elimination_order.append(si)
if self.record_log:
phase_events.append(f"{sp.name} eliminated!")
# Check game over
alive = [p for p in sim.players if not p.eliminated]
final_turn = turn + 1
if self.record_log:
game_log.append(turn_entry)
if len(alive) <= 1:
break
result = self._build_result(sim, final_turn, elimination_order)
if self.record_log:
result.game_log = game_log
return result
# ──────────────────────────────────────────────────────────
# Internal helpers
# ──────────────────────────────────────────────────────────
def _create_state(
self,
decks: list[list[Card]],
names: list[str],
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands (N players)."""
sim = SimState(max_turns=self.max_turns)
for idx, (deck, name) in enumerate(zip(decks, names)):
# Enrich + deep copy cards
cards = [enrich_card(c.clone()) for c in deck]
random.shuffle(cards)
hand = cards[:7]
library = cards[7:]
player = Player(
name=name,
life=self.starting_life,
owner_id=idx,
library=library,
hand=hand,
stats=PlayerStats(cards_drawn=7),
)
sim.players.append(player)
sim.init_battlefields(len(sim.players))
return sim
def _play_land(self, sim: SimState, pi: int) -> None:
"""Play the first land from hand onto the battlefield."""
p = sim.players[pi]
land_idx = -1
for i, card in enumerate(p.hand):
if card.is_land():
land_idx = i
break
if land_idx == -1:
return
land_card = p.hand.pop(land_idx)
land_card.owner_id = pi
land_card.tapped = False
land_card.id = sim.next_card_id
sim.next_card_id += 1
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.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:
"""Play up to 2 spells from hand, prioritized by AI score."""
p = sim.players[pi]
w = self.weights
# Score playable non-land cards
playable = []
for i, card in enumerate(p.hand):
if card.is_land():
continue
cmc = card.cmc or 0
if cmc <= available_mana:
playable.append((score_card(card, w), i, card))
playable.sort(key=lambda x: -x[0])
played = 0
while playable and played < 2:
_, _, best_card = playable.pop(0)
hand_pos = -1
for i, c in enumerate(p.hand):
if c is best_card:
hand_pos = i
break
if hand_pos == -1:
continue
card = p.hand.pop(hand_pos)
card.owner_id = pi
card.tapped = False
card.id = sim.next_card_id
sim.next_card_id += 1
card.turn_played = sim.turn
# Pay mana
mana_needed = card.cmc or 0
for bf_card in sim.get_battlefield(pi):
if mana_needed <= 0:
break
if not bf_card.tapped and bf_card.is_land():
bf_card.tapped = True
mana_needed -= 1
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal — target the scariest creature across all opponents
if card.is_removal:
p.stats.removal_used += 1
all_opp_creatures = []
for oi in range(len(sim.players)):
if oi == pi or sim.players[oi].eliminated:
continue
for c in sim.get_battlefield(oi):
if c.is_creature():
all_opp_creatures.append(c)
if all_opp_creatures:
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
killed = all_opp_creatures[0]
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}")
else:
if events is not None:
events.append(f"Cast {card.name} (removal, no targets)")
p.graveyard.append(card)
elif card.is_board_wipe:
p.stats.board_wipes_used += 1
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_names)} creatures")
else:
sim.add_to_battlefield(pi, card)
if card.is_creature():
p.stats.creatures_played += 1
if events is not None:
events.append(f"Cast {card.name} ({card.pt or 'creature'}) for {card.cmc} mana")
elif card.is_ramp:
p.stats.ramp_played += 1
if events is not None:
events.append(f"Cast {card.name} (ramp) for {card.cmc} mana")
else:
if events is not None:
events.append(f"Cast {card.name} for {card.cmc} mana")
played += 1
# Recalculate available mana for next spell
available_mana = self._count_untapped_lands(sim, pi)
@staticmethod
def _select_attack_target(sim: SimState, pi: int) -> int:
"""Select the best opponent to attack.
Multiplayer heuristic: attack the weakest non-eliminated opponent
(lowest life total). Ties broken by highest seat index (arbitrary
but deterministic).
Returns the seat index of the chosen target, or -1 if no valid target.
"""
best_seat = -1
best_life = float('inf')
for si, sp in enumerate(sim.players):
if si == pi or sp.eliminated:
continue
if sp.life < best_life:
best_life = sp.life
best_seat = si
return best_seat
def _resolve_combat(self, sim: SimState, pi: int, turn: int, events: list | None = None) -> None:
"""Resolve combat phase for a player (N-player ready)."""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return # no valid targets
opp = sim.players[opp_idx]
w = self.weights
# My creatures that can attack (not tapped, not summoning sick)
my_creatures = [
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.get_battlefield(opp_idx)
if (
c.is_creature()
and not c.tapped
)
]
# ── Decide attackers ──
attackers = []
total_my_power = sum(c.get_power() for c in my_creatures)
for atk in my_creatures:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
has_flying = atk.has_keyword("flying")
has_trample = atk.has_keyword("trample")
has_haste = atk.has_keyword("haste")
if has_flying or has_trample or has_haste or a_pow >= 3 or opp.life <= total_my_power:
attackers.append(atk)
atk.tapped = True
continue
can_die_profitably = any(
(
(not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
and (b.get_power() >= a_tou or b.has_keyword("deathtouch"))
and b.get_toughness() > a_pow
)
for b in opp_blockers
)
if not can_die_profitably or p.life > 25:
attackers.append(atk)
atk.tapped = True
if events is not None and attackers:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
# ── Blocking and damage ──
total_damage = 0
used_blockers: set[int] = set()
combat_details: list[str] = []
for atk in attackers:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
has_flying = atk.has_keyword("flying")
valid_blockers = [
b
for b in opp_blockers
if (
b.id not in used_blockers
and (not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
)
]
blocked = False
if valid_blockers and not atk.has_keyword("menace"):
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
if blocker:
used_blockers.add(blocker.id)
b_pow = blocker.get_power()
blocked = True
if events is not None:
combat_details.append(f"{atk.name} blocked by {blocker.name}")
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
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.remove_from_battlefield(atk.id)
sim.players[pi].graveyard.append(atk)
if events is not None:
combat_details.append(f" {atk.name} dies")
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
total_damage += a_pow - blocker.get_toughness()
if atk.has_keyword("lifelink"):
p.life += min(a_pow, blocker.get_toughness())
if not blocked:
total_damage += a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
opp.life -= total_damage
p.stats.damage_dealt += total_damage
opp.stats.damage_received += total_damage
if events is not None and total_damage > 0:
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
events.extend(combat_details)
if opp.life <= 0:
opp.eliminated = True
def _build_result(
self,
sim: SimState,
final_turn: int,
elimination_order: list[int] | None = None,
) -> GameResult:
"""Determine winner and build result object (N-player).
Finish positions:
1 = winner (last player standing, or highest life if timeout)
2..N = elimination order (last eliminated = 2nd place, etc.)
"""
if elimination_order is None:
elimination_order = []
n = len(sim.players)
# Determine winner: last player standing, or highest life among alive
alive = [i for i, p in enumerate(sim.players) if not p.eliminated]
if len(alive) == 1:
winner = alive[0]
elif len(alive) == 0:
# Everyone dead — shouldn't happen but pick highest life
winner = max(range(n), key=lambda i: sim.players[i].life)
else:
# Timeout — alive player with highest life wins
winner = max(alive, key=lambda i: sim.players[i].life)
# Build finish positions
# Winner = 1, then reverse elimination order (last eliminated = 2nd, etc.)
finish: dict[int, int] = {winner: 1}
position = 2
for seat in reversed(elimination_order):
if seat != winner:
finish[seat] = position
position += 1
# Any alive non-winner seats get next positions (by life descending)
remaining_alive = sorted(
[i for i in alive if i != winner],
key=lambda i: -sim.players[i].life,
)
for seat in remaining_alive:
if seat not in finish:
finish[seat] = position
position += 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=finish.get(seat, n),
stats=p.stats,
))
return GameResult(
winner_seat=winner,
turns=min(final_turn, sim.max_turns),
players=player_results,
)