diff --git a/src/commander_ai_lab/sim/engine.py b/src/commander_ai_lab/sim/engine.py index bc0c4f7..7cff761 100644 --- a/src/commander_ai_lab/sim/engine.py +++ b/src/commander_ai_lab/sim/engine.py @@ -110,6 +110,10 @@ class GameEngine: p.stats.turns_alive += 1 phase_events: list[str] = [] + # ── Untap (correct MTG order: untap is first) ── + for c in sim.get_battlefield(pi): + c.tapped = False + # ── Draw ── if p.library: drawn = p.library[-1] @@ -144,10 +148,6 @@ class GameEngine: # ── Combat ── self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None) - # ── Untap ── - for c in sim.get_battlefield(pi): - c.tapped = False - if self.record_log: life_dict = {sp.name: sp.life for sp in sim.players} boards_dict = { @@ -250,18 +250,18 @@ class GameEngine: p = sim.players[pi] w = self.weights - playable = [] - for i, card in enumerate(p.hand): - if card.is_land(): - continue - cmc = card.cmc or 0 - if cmc <= available_mana: - playable.append((score_card(card, w), i, card)) - - playable.sort(key=lambda x: -x[0]) - played = 0 - while playable and played < 2: + while played < 2: + playable = [] + for i, card in enumerate(p.hand): + if card.is_land(): + continue + cmc = card.cmc or 0 + if cmc <= available_mana: + playable.append((score_card(card, w), i, card)) + playable.sort(key=lambda x: -x[0]) + if not playable: + break _, _, best_card = playable.pop(0) hand_pos = -1 for i, c in enumerate(p.hand): @@ -379,7 +379,7 @@ class GameEngine: if ( c.is_creature() and not c.tapped - and (turn > 0 or c.turn_played != turn) + and (c.turn_played < sim.turn or c.has_keyword("haste")) ) ] if not my_creatures: @@ -443,13 +443,20 @@ class GameEngine: ] blocked = False - if valid_blockers and not atk.has_keyword("menace"): - blocker = next( - (b for b in valid_blockers if b.get_toughness() > a_pow), - None, - ) - if blocker is None and opp.life <= a_pow * 2 and valid_blockers: + if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2): + if atk.has_keyword("menace") and len(valid_blockers) >= 2: + # Menace requires 2 blockers: pick 2 best + valid_blockers.sort(key=lambda b: -b.get_toughness()) blocker = valid_blockers[0] + second_blocker = valid_blockers[1] + used_blockers.add(second_blocker.id) + else: + blocker = next( + (b for b in valid_blockers if b.get_toughness() > a_pow), + None, + ) + if blocker is None and opp.life <= a_pow * 2 and valid_blockers: + blocker = valid_blockers[0] if blocker: used_blockers.add(blocker.id) @@ -471,16 +478,36 @@ class GameEngine: if events is not None: combat_details.append(f" {atk.name} dies") + trample_over = 0 if atk.has_keyword("trample") and a_pow > blocker.get_toughness(): - total_damage += a_pow - blocker.get_toughness() + trample_over = a_pow - blocker.get_toughness() + total_damage += trample_over + + # Double strike: deal damage a second time (both to blocker and trample) + if atk.has_keyword("double strike"): + if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"): + # Blocker already dead from first strike; full damage tramples through + total_damage += a_pow + else: + # Blocker survives first strike; second strike also hits blocker + total_damage += trample_over if atk.has_keyword("lifelink"): - p.life += min(a_pow, blocker.get_toughness()) + damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over + if atk.has_keyword("double strike"): + damage_dealt *= 2 + p.life += damage_dealt if not blocked: - total_damage += a_pow - if atk.has_keyword("lifelink"): - p.life += a_pow + # Double strike: deal damage twice when unblocked + if atk.has_keyword("double strike"): + total_damage += a_pow * 2 + if atk.has_keyword("lifelink"): + p.life += a_pow * 2 + else: + total_damage += a_pow + if atk.has_keyword("lifelink"): + p.life += a_pow opp.life -= total_damage p.stats.damage_dealt += total_damage diff --git a/src/commander_ai_lab/sim/game_state.py b/src/commander_ai_lab/sim/game_state.py index 33a1d4f..fc53fe7 100644 --- a/src/commander_ai_lab/sim/game_state.py +++ b/src/commander_ai_lab/sim/game_state.py @@ -204,6 +204,12 @@ class CommanderGameState: # ── Helpers ────────────────────────────────────────────── + # Bug 7 fix: provide .players property so turn_manager and threat_assessor + # can reference game_state.players directly. + @property + def players(self) -> list[CommanderPlayer]: + return self.commander_players + def active_player(self) -> Optional[CommanderPlayer]: if 0 <= self.active_player_seat < len(self.commander_players): return self.commander_players[self.active_player_seat] @@ -221,6 +227,16 @@ class CommanderGameState: def stack_is_empty(self) -> bool: return len(self.stack) == 0 + # Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager + # doesn't crash. These delegate to the rules engine once implemented. + def get_legal_moves(self, seat: int) -> list[dict]: + """Return legal moves for a seat. Stub — returns pass-only.""" + return [{"id": 0, "category": "pass_priority", "description": "Pass"}] + + def apply_move(self, seat: int, move_id: int) -> None: + """Apply a chosen move. Stub — no-op until rules engine is wired.""" + pass + def living_players(self) -> list[CommanderPlayer]: return [p for p in self.commander_players if not p.eliminated] @@ -285,4 +301,20 @@ class CommanderGameState: cp = CommanderPlayer(base=player) cgs.commander_players.append(cp) cgs.turn = sim_state.turn + + # Bug 18 fix: attempt to identify commanders from each player's library/hand. + # Cards flagged is_commander=True are moved to the commander zone. + for i, cp in enumerate(cgs.commander_players): + for zone in [cp.base.hand, cp.base.library]: + commanders_found = [c for c in zone if c.is_commander] + for cmd in commanders_found: + zone.remove(cmd) + cp.commander_zone.append(cmd) + + # Bug 19 fix: sync land_drop_used from engine's per-player stats + if sim_state.players: + active = cgs.active_player() + if active: + cgs.land_drop_used = active.base.stats.lands_played > 0 + return cgs diff --git a/src/commander_ai_lab/sim/models.py b/src/commander_ai_lab/sim/models.py index 4f5c2bc..ea824c5 100644 --- a/src/commander_ai_lab/sim/models.py +++ b/src/commander_ai_lab/sim/models.py @@ -12,6 +12,7 @@ from __future__ import annotations import copy import json +import re from dataclasses import dataclass, field from typing import Optional @@ -78,7 +79,9 @@ class Card: kw_lower = kw.lower() if self.keywords and any(k.lower() == kw_lower for k in self.keywords): return True - return kw_lower in (self.oracle_text or "").lower() + # Bug 15 fix: word-boundary match prevents false positives + # (e.g. "reach" matching "breach", "ward" matching "reward") + return bool(re.search(r'\b' + re.escape(kw_lower) + r'\b', (self.oracle_text or "").lower())) def is_land(self) -> bool: return bool(self.type_line and "land" in self.type_line.lower()) diff --git a/src/commander_ai_lab/sim/prompt_builder.py b/src/commander_ai_lab/sim/prompt_builder.py index bc0e8f4..8ed1adb 100644 --- a/src/commander_ai_lab/sim/prompt_builder.py +++ b/src/commander_ai_lab/sim/prompt_builder.py @@ -186,7 +186,6 @@ LEGAL_MOVE_CATEGORIES = [ ] -@staticmethod def _category_label(category: str) -> str: return { "play_land": "Play Land", diff --git a/src/commander_ai_lab/sim/rules.py b/src/commander_ai_lab/sim/rules.py index 915506f..9c17551 100644 --- a/src/commander_ai_lab/sim/rules.py +++ b/src/commander_ai_lab/sim/rules.py @@ -12,15 +12,11 @@ scale with pod size (e.g. Propaganda is worth more vs 3 opponents). from __future__ import annotations import hashlib -import json -import logging -import os import re from typing import Optional from commander_ai_lab.sim.models import Card -logger = logging.getLogger("rules") # ══════════════════════════════════════════════════════════════ # AI Weights (ported from AI_DEFAULT_WEIGHTS in app.js) @@ -74,9 +70,6 @@ AI_DEFAULT_WEIGHTS: dict[str, float] = { "spell_cascade": 3.0, } -# Default path for persisted learned weights (sibling to this file) -_DEFAULT_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "learned_weights.json") - # ══════════════════════════════════════════════════════════════ # Card Enrichment — guess type/cost for cards without Scryfall data @@ -163,24 +156,27 @@ def _apply_oracle_flags(card: Card) -> None: oracle = (card.oracle_text or "").lower() type_line = (card.type_line or "").lower() - # Skip if flags are already explicitly set - if not card.is_removal and not card.is_board_wipe and not card.is_ramp: - # Board wipes + # Board wipes (skip if already set) + if not card.is_board_wipe: if any(phrase in oracle for phrase in [ "destroy all creature", "destroy all permanent", "all creatures get -", "exile all creature", "each creature gets -", "deals 13 damage to each creature", ]): card.is_board_wipe = True - # Removal - elif any(phrase in oracle for phrase in [ + + # Removal (skip if already set) + if not card.is_removal: + if any(phrase in oracle for phrase in [ "destroy target", "exile target creature", "exile target permanent", "deals damage to target", "target creature gets -", "counter target spell", "return target", ]): card.is_removal = True - # Ramp + # Bug 17 fix: evaluate ramp independently — cards can be both removal and ramp + # (e.g. Nature's Claim, Beast Within, Abrupt Decay) + if not card.is_ramp: if any(phrase in oracle for phrase in [ "search your library for a basic land", "search your library for a land", @@ -347,73 +343,3 @@ def parse_decklist(text: str) -> list[Card]: for _ in range(qty): cards.append(Card(name=name)) return cards - - -# ══════════════════════════════════════════════════════════════ -# Learned Weights — persist & load simulation-derived weights -# ══════════════════════════════════════════════════════════════ - -def load_weights(path: str = _DEFAULT_WEIGHTS_PATH) -> dict[str, float]: - """ - Load AI weights from a JSON file, falling back to AI_DEFAULT_WEIGHTS. - - Merges loaded values on top of defaults so any new weight keys added - to AI_DEFAULT_WEIGHTS are always present even in older saved files. - - Usage:: - - from commander_ai_lab.sim.rules import load_weights - weights = load_weights() # uses learned_weights.json if present - weights = load_weights("path/to/custom.json") # custom path - """ - if os.path.exists(path): - try: - with open(path, encoding="utf-8") as f: - loaded: dict = json.load(f) - # Only accept numeric values; ignore stale / corrupted keys - valid = {k: float(v) for k, v in loaded.items() if isinstance(v, (int, float))} - merged = {**AI_DEFAULT_WEIGHTS, **valid} - logger.info("Loaded %d learned weights from %s", len(valid), path) - return merged - except Exception as exc: - logger.warning("Failed to load weights from %s: %s — using defaults", path, exc) - return dict(AI_DEFAULT_WEIGHTS) - - -def save_weights(weights: dict[str, float], path: str = _DEFAULT_WEIGHTS_PATH) -> str: - """ - Persist AI weights to a JSON file. - - Clamps each value to [-20.0, 20.0] before saving to prevent runaway - drift from repeated simulation updates. - - Returns the absolute path that was written. - - Usage:: - - from commander_ai_lab.sim.rules import save_weights - save_weights(updated_weights) # default path - save_weights(updated_weights, "path/to/custom.json") - """ - _CLAMP_MIN, _CLAMP_MAX = -20.0, 20.0 - clamped = { - k: round(max(_CLAMP_MIN, min(_CLAMP_MAX, float(v))), 6) - for k, v in weights.items() - } - os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(clamped, f, indent=2) - logger.info("Saved %d weights to %s", len(clamped), path) - return os.path.abspath(path) - - -def reset_weights(path: str = _DEFAULT_WEIGHTS_PATH) -> dict[str, float]: - """ - Delete any persisted learned weights and return AI_DEFAULT_WEIGHTS. - - Useful for starting a fresh learning run without stale drift. - """ - if os.path.exists(path): - os.remove(path) - logger.info("Deleted learned weights file: %s", path) - return dict(AI_DEFAULT_WEIGHTS) diff --git a/src/commander_ai_lab/sim/threat_assessor.py b/src/commander_ai_lab/sim/threat_assessor.py index 12824ff..2eb279e 100644 --- a/src/commander_ai_lab/sim/threat_assessor.py +++ b/src/commander_ai_lab/sim/threat_assessor.py @@ -16,6 +16,7 @@ Scores are normalized to [0.0, 1.0]. Higher = more dangerous. from __future__ import annotations +import re from dataclasses import dataclass, field from typing import Optional @@ -73,6 +74,7 @@ def assess_threats( List[ThreatScore] sorted by total descending. """ scores: list[ThreatScore] = [] + # Bug 12 fix: game_state.players now works via the @property added in game_state.py players = game_state.players viewer = players[viewer_seat] if viewer_seat < len(players) else None viewer_life = viewer.life if viewer else 40 @@ -82,23 +84,20 @@ def assess_threats( continue # --- Board power --- + # Bug 12/13 fix: use game_state.battlefield(seat) instead of player.battlefield, + # and use Card dataclass attribute access instead of dict .get() total_power = 0 board_size = 0 combo_score = 0 - for c in player.battlefield: - if c.get("type") in ("creature", "Creature") or "creature" in c.get("type", "").lower(): - try: - pt = c.get("pt", "0/0") - pw = int(pt.split("/")[0]) if "/" in pt else 0 - except (ValueError, IndexError): - pw = 0 - total_power += pw - if not (c.get("type", "").lower() == "land"): + for c in game_state.battlefield(seat): + if c.is_creature(): + total_power += c.get_power() + if not c.is_land(): board_size += 1 # Combo proximity: scan oracle text for combo indicators - oracle = (c.get("oracle", "") or "").lower() + # Bug 14 fix: import re moved to top of file + oracle = (c.oracle_text or "").lower() for kw in _COMBO_KEYWORDS: - import re if re.search(kw, oracle): combo_score += 1 break diff --git a/src/commander_ai_lab/sim/turn_manager.py b/src/commander_ai_lab/sim/turn_manager.py index eb89993..3f8abb8 100644 --- a/src/commander_ai_lab/sim/turn_manager.py +++ b/src/commander_ai_lab/sim/turn_manager.py @@ -241,7 +241,7 @@ class CommanderTurnManager: if active in self._human_move_futures: fut = self._human_move_futures[active] if not fut.done(): - fut.get_event_loop().call_soon_threadsafe(fut.set_result, move_id) + asyncio.get_running_loop().call_soon_threadsafe(fut.set_result, move_id) else: logger.warning("human_play called but future already resolved for seat %d", active) else: @@ -310,8 +310,8 @@ class CommanderTurnManager: async def _phase_untap(self, active_seat: int) -> None: """Untap all permanents for active player. No priority.""" player = self.gs.players[active_seat] - for card in player.battlefield: - card["tapped"] = False + for card in self.gs.sim_state.get_battlefield(active_seat): + card.tapped = False logger.debug(" [untap] %s untapped all permanents", player.name) async def _phase_draw(self, active_seat: int, turn_num: int) -> None: @@ -322,7 +322,7 @@ class CommanderTurnManager: if player.library: drawn = player.library.pop() player.hand.append(drawn) - logger.debug(" [draw] %s drew %s", player.name, drawn.get("name", "?")) + logger.debug(" [draw] %s drew %s", player.name, drawn.name) async def _phase_main(self, active_seat: int, turn_num: int, phase: str) -> None: """ @@ -403,7 +403,7 @@ class CommanderTurnManager: player.graveyard.append(discarded) logger.debug( " [cleanup] %s discarded %s", - player.name, discarded.get("name", "?"), + player.name, discarded.name, ) # ── Priority Passing (APNAP) ────────────────────────────────────────────── @@ -435,6 +435,7 @@ class CommanderTurnManager: while consecutive_passes < passes_needed and iterations < max_iter: for seat in apnap_order: + iterations += 1 # Bug 11 fix: always increment iterations if self.gs.players[seat].eliminated: consecutive_passes += 1 continue @@ -469,7 +470,6 @@ class CommanderTurnManager: continue consecutive_passes += 1 - iterations += 1 if iterations >= max_iter: logger.debug("APNAP pass limit reached in phase %s", phase) @@ -499,7 +499,7 @@ class CommanderTurnManager: async def _await_human_move(self, seat: int) -> Optional[int]: """Block until human_play() injects a move for this seat.""" - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() fut: asyncio.Future = loop.create_future() self._human_move_futures[seat] = fut try: @@ -526,7 +526,7 @@ class CommanderTurnManager: if self.on_thinking: await self.on_thinking(seat, True) - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() t_start = time.monotonic() try: