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
This commit is contained in:
KoalaTrapLord committed 2026-03-13 16:54:55 -04:00
1 parent ddc0e4fc05
commit 0be8841cec
1 file changed
+129 -70
+129 -70
View File
@@ -4,12 +4,15 @@ 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:
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
@@ -34,12 +37,15 @@ from commander_ai_lab.sim.rules import (
class GameEngine:
"""
Headless Commander game engine.
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__(
@@ -65,15 +71,30 @@ 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
"""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(2):
for pi in range(len(sim.players)):
p = sim.players[pi]
if p.eliminated:
continue
@@ -92,7 +113,6 @@ 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()),
@@ -105,7 +125,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,8 +133,6 @@ 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)
# ── Untap ──
@@ -123,15 +140,31 @@ class GameEngine:
c.tapped = False
if self.record_log:
turn_entry["phases"].append({
# 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": {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_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]
@@ -139,15 +172,9 @@ class GameEngine:
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)
result = self._build_result(sim, final_turn, elimination_order)
if self.record_log:
result.game_log = game_log
return result
@@ -158,15 +185,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 with shuffled decks and opening hands (N players)."""
sim = SimState(max_turns=self.max_turns)
for idx, (deck, name) in enumerate([(deck_a, name_a), (deck_b, name_b)]):
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)
@@ -260,18 +285,19 @@ class GameEngine:
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal
# Handle removal — target the scariest creature across all opponents
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]
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:
@@ -317,16 +343,35 @@ class GameEngine:
# 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."""
"""Resolve combat phase for a player (N-player ready)."""
p = sim.players[pi]
opp_idx = 1 - 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
if opp.eliminated:
return
# My creatures that can attack (not tapped, not summoning sick)
my_creatures = [
c
@@ -350,9 +395,6 @@ class GameEngine:
]
# ── 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 +404,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 +446,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 +461,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())
@@ -466,20 +499,46 @@ class GameEngine:
self,
sim: SimState,
final_turn: int,
name_a: str,
name_b: str,
elimination_order: list[int] | None = None,
) -> GameResult:
"""Determine winner and build result object."""
pa = sim.players[0]
pb = sim.players[1]
"""Determine winner and build result object (N-player).
if pa.eliminated and not pb.eliminated:
winner = 1
elif not pa.eliminated and pb.eliminated:
winner = 0
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:
# Both alive or both dead: compare life
winner = 0 if pa.life >= pb.life else 1
# 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):
@@ -488,7 +547,7 @@ class GameEngine:
name=p.name,
life=p.life,
eliminated=p.eliminated,
finish_position=1 if seat == winner else 2,
finish_position=finish.get(seat, n),
stats=p.stats,
))