From 0be8841cecc623d6740c11589926b5194be04a7b Mon Sep 17 00:00:00 2001 From: KoalaTrapLord Date: Fri, 13 Mar 2026 16:54:55 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20N-player=20engine=20turn=20loop=20(Task?= =?UTF-8?q?=202A=20=E2=80=94=20Issue=20#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/commander_ai_lab/sim/engine.py | 199 +++++++++++++++++++---------- 1 file changed, 129 insertions(+), 70 deletions(-) diff --git a/src/commander_ai_lab/sim/engine.py b/src/commander_ai_lab/sim/engine.py index ceb365f..65d727d 100644 --- a/src/commander_ai_lab/sim/engine.py +++ b/src/commander_ai_lab/sim/engine.py @@ -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, ))