From fa43fe3ecde86eaae5b950fd02086e83604e7843 Mon Sep 17 00:00:00 2001 From: KoalaTrapLord Date: Thu, 12 Mar 2026 14:31:45 -0400 Subject: [PATCH] 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 --- src/commander_ai_lab/sim/engine.py | 284 +++++++++++++++++++---------- 1 file changed, 184 insertions(+), 100 deletions(-) diff --git a/src/commander_ai_lab/sim/engine.py b/src/commander_ai_lab/sim/engine.py index ceb365f..bb0abb1 100644 --- a/src/commander_ai_lab/sim/engine.py +++ b/src/commander_ai_lab/sim/engine.py @@ -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, )