refactor: upgrade GameEngine to N-player (Commander pod support)

- run() now accepts any number of decks/names via *decks / *names
- Turn loop iterates over len(sim.players) instead of hardcoded range(2)
- _create_state() builds N players from parallel deck/name lists
- _play_spells() removal picks highest-value creature from any live opponent
- _resolve_combat() targets the most threatening alive opponent (highest
  total board power), not just seat `1 - pi`
- _build_result() assigns correct finish_position to all N players based on
  elimination order tracked via sim.elimination_order
- Game log lifeAfter now records all players dynamically
- Backward-compatible: run(deck_a, deck_b) still works as before

Closes #27
This commit is contained in:
KoalaTrapLord committed 2026-03-12 14:31:45 -04:00
1 parent d0a1c10372
commit fa43fe3ecd
1 file changed
+175 -91
+175 -91
View File
@@ -4,12 +4,13 @@ Commander AI Lab — Headless Game Engine
A headless Commander game simulator for Monte Carlo analysis.
Faithful port of dtSimGame() / dtCreatePlayer() from mtg-commander-lan.
Simulates simplified 1v1 Commander games:
Supports 2–4 player Commander pods (N-player).
- 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
- Creature removal and board wipe handling
- Win by elimination (life <= 0) or life comparison at max turns
- Multiplayer threat targeting: attacks and removal target highest-threat opponent
"""
from __future__ import annotations
@@ -34,12 +35,19 @@ from commander_ai_lab.sim.rules import (
class GameEngine:
"""
Headless Commander game engine.
Headless Commander game engine — supports 2–4 player pods.
Usage::
2-player usage (backward-compatible)::
engine = GameEngine(max_turns=25)
result = engine.run(deck_a, deck_b, name_a="Deck A", name_b="Deck B")
N-player usage::
result = engine.run_pod(
decks=[deck_a, deck_b, deck_c, deck_d],
names=["Alice", "Bob", "Carol", "Dave"],
)
"""
def __init__(
@@ -65,15 +73,33 @@ class GameEngine:
name_a: str = "Player A",
name_b: str = "Player B",
) -> GameResult:
"""Run a single headless game and return the result."""
sim = self._create_state(deck_a, deck_b, name_a, name_b)
game_log: list[dict] = [] # turn-by-turn log
"""Run a 1v1 headless game. Backward-compatible wrapper around run_pod()."""
return self.run_pod(decks=[deck_a, deck_b], names=[name_a, name_b])
def run_pod(
self,
decks: list[list[Card]],
names: Optional[list[str]] = None,
) -> GameResult:
"""Run an N-player pod game (2–4 players) and return the result."""
if not decks or len(decks) < 2:
raise ValueError("run_pod requires at least 2 decks")
if names is None:
names = [f"Player {chr(65 + i)}" for i in range(len(decks))]
if len(names) != len(decks):
raise ValueError("len(names) must equal len(decks)")
sim = self._create_state(decks, names)
game_log: list[dict] = []
# Track elimination order: first eliminated = highest finish_position number
sim.elimination_order: list[int] = [] # seat indices in elimination order
final_turn = 0
for turn in range(sim.max_turns):
sim.turn = turn
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(2):
for pi in range(len(sim.players)):
p = sim.players[pi]
if p.eliminated:
continue
@@ -82,7 +108,7 @@ class GameEngine:
# ── Draw ──
if p.library:
drawn = p.library[-1] # peek before pop
drawn = p.library[-1]
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
@@ -92,10 +118,8 @@ class GameEngine:
land_before = p.stats.lands_played
self._play_land(sim, pi)
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.get_battlefield(pi))
if c.is_land()),
(c for c in reversed(sim.get_battlefield(pi)) if c.is_land()),
None,
)
if last_land:
@@ -105,7 +129,6 @@ class GameEngine:
available_mana = self._count_untapped_lands(sim, pi)
# ── Play spells (up to 2) ──
spells_before = p.stats.spells_cast
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
# ── Track board size ──
@@ -114,40 +137,42 @@ class GameEngine:
p.stats.max_board_size = board_size
# ── Combat ──
combat_before_life_a = sim.players[0].life
combat_before_life_b = sim.players[1].life
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
# ── Check for new eliminations after combat ──
for opp_idx, opp in enumerate(sim.players):
if not opp.eliminated and opp.life <= 0:
opp.eliminated = True
sim.elimination_order.append(opp_idx)
if self.record_log:
phase_events.append(f"{opp.name} eliminated!")
# ── Untap ──
for c in sim.get_battlefield(pi):
c.tapped = False
if self.record_log:
life_snapshot = {sim.players[s].name: sim.players[s].life
for s in range(len(sim.players))}
boards = {f"board_{s}": [c.name for c in sim.get_battlefield(s) if c.is_creature()]
for s in range(len(sim.players))}
turn_entry["phases"].append({
"player": p.name,
"playerId": pi,
"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.get_battlefield(0) if c.is_creature()],
"boardB": [c.name for c in sim.get_battlefield(1) if c.is_creature()],
"lifeAfter": life_snapshot,
**boards,
})
# 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:
if self.record_log:
elim_name = next((p.name for p in sim.players if p.eliminated), None)
if elim_name:
game_log[-1]["event"] = f"{elim_name} eliminated!"
break
if self.record_log:
game_log.append(turn_entry) if turn_entry not in game_log else None
result = self._build_result(sim, final_turn, name_a, name_b)
# Mark any remaining alive players as not-eliminated (they survived)
result = self._build_result(sim, final_turn)
if self.record_log:
result.game_log = game_log
return result
@@ -158,16 +183,13 @@ class GameEngine:
def _create_state(
self,
deck_a: list[Card],
deck_b: list[Card],
name_a: str,
name_b: str,
decks: list[list[Card]],
names: list[str],
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands."""
"""Create initial simulation state for N players."""
sim = SimState(max_turns=self.max_turns)
for idx, (deck, name) in enumerate([(deck_a, name_a), (deck_b, name_b)]):
# Enrich + deep copy cards
for idx, (deck, name) in enumerate(zip(decks, names)):
cards = [enrich_card(c.clone()) for c in deck]
random.shuffle(cards)
@@ -214,12 +236,65 @@ class GameEngine:
if not c.tapped and c.is_land()
)
def _pick_removal_target_seat(self, sim: SimState, pi: int) -> int:
"""Pick the best opponent seat to target with removal.
Returns the seat index of the alive opponent with the highest-value
creature on the battlefield. Falls back to any alive opponent.
"""
w = self.weights
best_seat = -1
best_score = -1
for opp_idx, opp in enumerate(sim.players):
if opp_idx == pi or opp.eliminated:
continue
top_creature = max(
(c for c in sim.get_battlefield(opp_idx) if c.is_creature()),
key=lambda c: score_card(c, w),
default=None,
)
if top_creature is not None:
s = score_card(top_creature, w)
if s > best_score:
best_score = s
best_seat = opp_idx
if best_seat == -1:
# No creatures anywhere — return first alive opponent
for opp_idx, opp in enumerate(sim.players):
if opp_idx != pi and not opp.eliminated:
return opp_idx
return best_seat
def _pick_attack_target_seat(self, sim: SimState, pi: int) -> int:
"""Pick the best opponent seat to attack.
Targets the alive opponent with the highest total board power
(i.e. biggest threat). If tied or no creatures, targets the
opponent with the lowest life (most vulnerable).
"""
alive_opps = [
(opp_idx, opp)
for opp_idx, opp in enumerate(sim.players)
if opp_idx != pi and not opp.eliminated
]
if not alive_opps:
return -1
# Score by: total opponent board power (threat) then lowest life (vulnerability)
def threat_key(entry):
opp_idx, opp = entry
board_power = sum(
c.get_power() for c in sim.get_battlefield(opp_idx) if c.is_creature()
)
return (board_power, -opp.life)
target_idx, _ = max(alive_opps, key=threat_key)
return target_idx
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():
@@ -260,14 +335,13 @@ class GameEngine:
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal
if card.is_removal:
p.stats.removal_used += 1
opp_idx_r = 1 - pi
# N-player: target highest-value opponent creature
target_seat = self._pick_removal_target_seat(sim, pi)
if target_seat != -1:
opp_creatures = [
c
for c in sim.get_battlefield(opp_idx_r)
if c.is_creature()
c for c in sim.get_battlefield(target_seat) if c.is_creature()
]
if opp_creatures:
opp_creatures.sort(key=lambda c: -score_card(c, w))
@@ -279,6 +353,9 @@ class GameEngine:
else:
if events is not None:
events.append(f"Cast {card.name} (removal, no targets)")
else:
if events is not None:
events.append(f"Cast {card.name} (removal, no targets)")
p.graveyard.append(card)
elif card.is_board_wipe:
@@ -313,19 +390,18 @@ class GameEngine:
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)
def _resolve_combat(self, sim: SimState, pi: int, turn: int, events: list | None = None) -> None:
"""Resolve combat phase for a player."""
"""Resolve combat phase: attacker targets the highest-threat alive opponent."""
p = sim.players[pi]
opp_idx = 1 - pi
opp = sim.players[opp_idx]
w = self.weights
if opp.eliminated:
# N-player: pick target opponent by threat level
opp_idx = self._pick_attack_target_seat(sim, pi)
if opp_idx == -1:
return
opp = sim.players[opp_idx]
# My creatures that can attack (not tapped, not summoning sick)
my_creatures = [
@@ -343,16 +419,10 @@ class GameEngine:
opp_blockers = [
c
for c in sim.get_battlefield(opp_idx)
if (
c.is_creature()
and not c.tapped
)
if c.is_creature() and not c.tapped
]
# ── Decide attackers ──
# Aggressive strategy: attack with most creatures.
# Only hold back small creatures (power < 3) when opponent has
# blockers that would profitably trade AND we have plenty of life.
attackers = []
total_my_power = sum(c.get_power() for c in my_creatures)
for atk in my_creatures:
@@ -362,29 +432,26 @@ class GameEngine:
has_trample = atk.has_keyword("trample")
has_haste = atk.has_keyword("haste")
# Always attack with evasion, big creatures, or if opponent is low
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
# For small creatures, check if a profitable block exists
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 # blocker survives = bad trade
and b.get_toughness() > a_pow
)
for b in opp_blockers
)
# Attack unless we'd lose the creature for nothing AND we're healthy
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 with: {', '.join(atk_names)}")
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
# ── Blocking and damage ──
total_damage = 0
@@ -407,12 +474,10 @@ class GameEngine:
blocked = False
if valid_blockers and not atk.has_keyword("menace"):
# Try to find a blocker that survives
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
# If life is critical, chump block
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
@@ -424,25 +489,21 @@ class GameEngine:
if events is not None:
combat_details.append(f"{atk.name} blocked by {blocker.name}")
# Attacker kills blocker?
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")
# Blocker kills attacker?
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")
# Trample overflow
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
total_damage += a_pow - blocker.get_toughness()
# Lifelink
if atk.has_keyword("lifelink"):
p.life += min(a_pow, blocker.get_toughness())
@@ -459,41 +520,64 @@ class GameEngine:
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) -> GameResult:
"""Determine winner and assign finish positions for all N players."""
num_players = len(sim.players)
elimination_order = getattr(sim, "elimination_order", [])
def _build_result(
self,
sim: SimState,
final_turn: int,
name_a: str,
name_b: str,
) -> GameResult:
"""Determine winner and build result object."""
pa = sim.players[0]
pb = sim.players[1]
# Players still alive at end of game — determine winner by life total
alive = [p for p in sim.players if not p.eliminated]
if pa.eliminated and not pb.eliminated:
winner = 1
elif not pa.eliminated and pb.eliminated:
winner = 0
if not alive:
# Edge case: everyone eliminated (e.g. simultaneous lethal)
winner_seat = -1
elif len(alive) == 1:
winner_seat = alive[0].owner_id
else:
# Both alive or both dead: compare life
winner = 0 if pa.life >= pb.life else 1
# Multiple players survived max_turns — highest life wins
winner_seat = max(
(p.owner_id for p in alive),
key=lambda idx: sim.players[idx].life,
)
player_results = []
for seat, p in enumerate(sim.players):
player_results.append(PlayerResult(
# Build finish positions:
# 1st = winner, then survivors ordered by life (desc), then eliminated in reverse order
# (last eliminated = best finish among eliminated)
survivor_seats = sorted(
[p.owner_id for p in alive if p.owner_id != winner_seat],
key=lambda idx: -sim.players[idx].life,
)
# elimination_order[0] = first out = worst finish
# Finish positions: winner=1, survivors=2..k, eliminated reversed
finish: dict[int, int] = {winner_seat: 1}
pos = 2
for seat in survivor_seats:
finish[seat] = pos
pos += 1
for seat in reversed(elimination_order):
if seat not in finish:
finish[seat] = pos
pos += 1
# Any remaining (shouldn't happen, but safety)
for seat in range(num_players):
if seat not in finish:
finish[seat] = pos
pos += 1
player_results = [
PlayerResult(
seat_index=seat,
name=p.name,
life=p.life,
eliminated=p.eliminated,
finish_position=1 if seat == winner else 2,
stats=p.stats,
))
name=sim.players[seat].name,
life=sim.players[seat].life,
eliminated=sim.players[seat].eliminated,
finish_position=finish.get(seat, num_players),
stats=sim.players[seat].stats,
)
for seat in range(num_players)
]
return GameResult(
winner_seat=winner,
winner_seat=winner_seat,
turns=min(final_turn, sim.max_turns),
players=player_results,
)