Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa43fe3ecd |
+184
-100
@@ -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,22 +335,24 @@ 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
|
||||
opp_creatures = [
|
||||
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.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}")
|
||||
# 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(target_seat) if c.is_creature()
|
||||
]
|
||||
if opp_creatures:
|
||||
opp_creatures.sort(key=lambda c: -score_card(c, w))
|
||||
killed = 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)")
|
||||
else:
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal, no targets)")
|
||||
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user