From 441ba7dd7414de5d55f8fca29771fe7190e906a9 Mon Sep 17 00:00:00 2001 From: KoalaTrapLord Date: Sat, 14 Mar 2026 16:53:43 -0400 Subject: [PATCH] Phase 2: Add ai_opponent.py, personality_prompts.py, tests/test_phase2.py --- src/commander_ai_lab/sim/ai_opponent.py | 521 ++++++++++++++++++ .../sim/personality_prompts.py | 169 ++++++ tests/test_phase2.py | 278 ++++++++++ 3 files changed, 968 insertions(+) create mode 100644 src/commander_ai_lab/sim/ai_opponent.py create mode 100644 src/commander_ai_lab/sim/personality_prompts.py create mode 100644 tests/test_phase2.py diff --git a/src/commander_ai_lab/sim/ai_opponent.py b/src/commander_ai_lab/sim/ai_opponent.py new file mode 100644 index 0000000..7a8021a --- /dev/null +++ b/src/commander_ai_lab/sim/ai_opponent.py @@ -0,0 +1,521 @@ +""" +Commander AI Lab — AIOpponent (Phase 2) +======================================== +High-level AI player that wraps DeepSeekBrain + Phase 1 prompt builder ++ personality prompts to produce per-turn decisions and narration. + +Design: + - Each seat gets one AIOpponent instance (supports 2–4 simultaneous). + - decide_action() builds the full LLM prompt using Phase 1 helpers, + calls GPT-OSS 20B via Ollama, parses the response, validates the + action index against legal_moves, and falls back to heuristic on failure. + - narrate_play() calls the LLM with a short narration prompt and + enforces a 30-word cap to prevent UI overflow. + - All fallback events are logged via StateLogger for tuning. + +Usage: + from commander_ai_lab.sim.ai_opponent import AIOpponent + from commander_ai_lab.sim.personality_prompts import AGGRO_TIMMY + + ai = AIOpponent( + name="Timmy", + seat=1, + personality=AGGRO_TIMMY, + deck=[...], # list[Card] + ) + ai.brain.check_connection() + + move_id = ai.decide_action(game_state, legal_moves) + flavor = ai.narrate_play(move_id, legal_moves) +""" + +from __future__ import annotations + +import json +import logging +import re +import time +from dataclasses import dataclass, field +from typing import Optional + +from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig +from commander_ai_lab.sim.game_state import CommanderGameState +from commander_ai_lab.sim.personality_prompts import Personality, AGGRO_TIMMY +from commander_ai_lab.sim.prompt_builder import build_full_prompt +from commander_ai_lab.sim.state_logger import StateLogger + +logger = logging.getLogger("ai_opponent") + +_MAX_NARRATION_WORDS = 30 + + +# ── Memory Log Entry ──────────────────────────────────────────────── + +@dataclass +class MemoryEntry: + """One entry in the AI's short-term memory log.""" + turn: int + phase: str + chosen_move_id: Optional[int] + move_description: str + narration: str + fallback_used: bool + latency_ms: float + + +# ── AIOpponent ───────────────────────────────────────────────────────── + +@dataclass +class AIOpponent: + """ + A single AI seat in a Commander game. + + Parameters + ---------- + name : str + Display name shown in the UI. + seat : int + Seat index in the game (0–3). + personality : Personality + One of the four archetype personalities from personality_prompts.py. + deck : list + List of Card objects for this player's deck (used for deck context). + brain_config : DeepSeekConfig | None + LLM configuration. Defaults to GPT-OSS 20B via Ollama localhost. + log_dir : str | None + Directory for StateLogger snapshots. Defaults to logs/snapshots/. + """ + + name: str + seat: int + personality: Personality = field(default_factory=lambda: AGGRO_TIMMY) + deck: list = field(default_factory=list) + brain_config: Optional[DeepSeekConfig] = None + log_dir: Optional[str] = None + + # Populated post-init + brain: DeepSeekBrain = field(init=False) + state_logger: StateLogger = field(init=False) + memory_log: list[MemoryEntry] = field(init=False, default_factory=list) + + # Stats + _total_decisions: int = field(init=False, default=0) + _total_fallbacks: int = field(init=False, default=0) + + def __post_init__(self) -> None: + self.brain = DeepSeekBrain(config=self.brain_config) + self.state_logger = StateLogger(log_dir=self.log_dir) + + # ── Core API ───────────────────────────────────────────────── + + def decide_action( + self, + game_state: CommanderGameState, + legal_moves: list[dict], + ) -> Optional[int]: + """ + Choose a legal move for this AI seat. + + Workflow: + 1. Build the full LLM prompt (Phase 1 helpers + personality). + 2. Send to GPT-OSS 20B via DeepSeekBrain. + 3. Parse LLM response for a move index. + 4. Validate the index is in legal_moves. + 5. On failure, fall back to heuristic and log the event. + + Args: + game_state: Current CommanderGameState. + legal_moves: List of legal move dicts from the rules engine. + Each dict: {"id": int, "category": str, "description": str, ...} + + Returns: + int: The chosen move's "id" value, or None if no legal moves. + """ + if not legal_moves: + return None + + t_start = time.time() + self._total_decisions += 1 + fallback_used = False + chosen_id: Optional[int] = None + + # Build the complete prompt + prompt = build_full_prompt( + gs=game_state, + viewer_seat=self.seat, + moves=legal_moves, + personality=self.personality.system_prompt, + ) + + # ── Attempt LLM decision ──────────────────────────────── + if self.brain._connected: + try: + chosen_id = self._call_llm_for_move(prompt, legal_moves) + except Exception as exc: + logger.warning( + "[%s seat=%d] LLM decision failed: %s — using fallback", + self.name, self.seat, exc, + ) + chosen_id = None + + # ── Fallback if LLM failed or not connected ──────────────── + if chosen_id is None: + fallback_used = True + self._total_fallbacks += 1 + chosen_id = self._heuristic_move(game_state, legal_moves) + logger.info( + "[%s seat=%d] Fallback triggered. Chose move id=%s", + self.name, self.seat, chosen_id, + ) + + latency_ms = round((time.time() - t_start) * 1000, 1) + move_desc = self._move_description(chosen_id, legal_moves) + + # ── Save snapshot for debugging ─────────────────────────── + try: + self.state_logger.save( + game_state=game_state, + prompt=prompt, + chosen_move_id=chosen_id, + seat=self.seat, + fallback_used=fallback_used, + extra={"latency_ms": latency_ms, "personality": self.personality.key}, + ) + except Exception as log_exc: + logger.warning("[%s] State logger failed: %s", self.name, log_exc) + + # ── Memory log ──────────────────────────────────────────── + self.memory_log.append(MemoryEntry( + turn=game_state.turn, + phase=game_state.current_phase, + chosen_move_id=chosen_id, + move_description=move_desc, + narration="", # filled in by narrate_play() + fallback_used=fallback_used, + latency_ms=latency_ms, + )) + + return chosen_id + + def narrate_play( + self, + move_id: Optional[int], + legal_moves: list[dict], + game_state: Optional[CommanderGameState] = None, + ) -> str: + """ + Generate a short in-character flavor line for the chosen action. + + Calls GPT-OSS 20B with a lightweight narration prompt. + Falls back to a canned line if the LLM is unavailable. + Always enforces the 30-word cap. + + Args: + move_id: The chosen move id (from decide_action). + legal_moves: Full legal moves list (to look up description). + game_state: Optional state for richer context. + + Returns: + A narration string of at most 30 words. + """ + move_desc = self._move_description(move_id, legal_moves) + narration = "" + + if self.brain._connected: + try: + narration = self._call_llm_for_narration(move_desc) + except Exception as exc: + logger.debug("[%s] Narration LLM failed: %s", self.name, exc) + + if not narration: + narration = self._canned_narration(move_desc) + + # Enforce word cap + words = narration.split() + if len(words) > _MAX_NARRATION_WORDS: + narration = " ".join(words[:_MAX_NARRATION_WORDS]) + "…" + + # Back-fill narration into most recent memory entry + if self.memory_log: + self.memory_log[-1].narration = narration + + return narration + + # ── LLM Helpers ──────────────────────────────────────────────── + + def _call_llm_for_move( + self, + prompt: str, + legal_moves: list[dict], + ) -> Optional[int]: + """ + Send the full prompt to the LLM and parse a move index from the response. + Returns the move id or None if parsing fails. + """ + from urllib.request import Request, urlopen + import json as _json + + cfg = self.brain.config + payload = { + "model": cfg.model, + "messages": [ + {"role": "user", "content": prompt}, + ], + "temperature": cfg.temperature, + "max_tokens": 64, # We only need a number + "top_p": cfg.top_p, + "stream": False, + } + + url = cfg.api_base.rstrip("/") + "/v1/chat/completions" + req_data = _json.dumps(payload).encode("utf-8") + req = Request(url, data=req_data, method="POST") + req.add_header("Content-Type", "application/json") + + with urlopen(req, timeout=cfg.request_timeout) as resp: + resp_data = _json.loads(resp.read()) + + choices = resp_data.get("choices", []) + if not choices: + return None + + raw = choices[0].get("message", {}).get("content", "").strip() + return self._parse_move_id(raw, legal_moves) + + def _call_llm_for_narration(self, move_desc: str) -> str: + """ + Call the LLM with a short narration prompt. + Returns raw narration text (word-capping applied in narrate_play). + """ + from urllib.request import Request, urlopen + import json as _json + + narration_prompt = ( + f"{self.personality.system_prompt}\n\n" + f"You just played: {move_desc}\n" + f"Give a single in-character reaction of at most {_MAX_NARRATION_WORDS} words. " + f"No explanation. Just the flavor line." + ) + + cfg = self.brain.config + payload = { + "model": cfg.model, + "messages": [{"role": "user", "content": narration_prompt}], + "temperature": 0.8, # Higher temperature for creative narration + "max_tokens": 80, + "stream": False, + } + + url = cfg.api_base.rstrip("/") + "/v1/chat/completions" + req_data = _json.dumps(payload).encode("utf-8") + req = Request(url, data=req_data, method="POST") + req.add_header("Content-Type", "application/json") + + with urlopen(req, timeout=10.0) as resp: + resp_data = _json.loads(resp.read()) + + choices = resp_data.get("choices", []) + if not choices: + return "" + return choices[0].get("message", {}).get("content", "").strip() + + # ── Response Parsing ─────────────────────────────────────────────── + + def _parse_move_id( + self, + raw: str, + legal_moves: list[dict], + ) -> Optional[int]: + """ + Extract a valid move id from the LLM's raw text response. + + Strategies (in order): + 1. Raw text is just a number → use it if valid. + 2. First integer found in text → use it if valid. + 3. JSON with an 'id' or 'action' key → extract and validate. + 4. Return None (triggers fallback). + """ + valid_ids = {m["id"] for m in legal_moves} + + # Strip think blocks (reasoning models) + raw = re.sub(r'.*?', '', raw, flags=re.DOTALL).strip() + + # Strategy 1: whole response is a number + stripped = raw.strip() + if stripped.isdigit(): + mid = int(stripped) + if mid in valid_ids: + return mid + + # Strategy 2: first integer in text + nums = re.findall(r'\b(\d+)\b', raw) + for n in nums: + mid = int(n) + if mid in valid_ids: + return mid + + # Strategy 3: JSON object with id/action key + json_match = re.search(r'\{.*?\}', raw, re.DOTALL) + if json_match: + try: + parsed = json.loads(json_match.group(0)) + for key in ("id", "move_id", "choice", "action"): + if key in parsed: + mid = int(parsed[key]) + if mid in valid_ids: + return mid + except (json.JSONDecodeError, ValueError, TypeError): + pass + + logger.debug("[%s] Could not parse move id from: %r", self.name, raw[:100]) + return None + + # ── Heuristic Fallback ─────────────────────────────────────────────── + + def _heuristic_move(self, game_state: CommanderGameState, legal_moves: list[dict]) -> int: + """ + Priority-based heuristic fallback. + + Priority order (personality-agnostic safe default): + 1. play_land + 2. cast_commander + 3. cast_spell + 4. attack + 5. activate_ability + 6. pass_priority / pass_turn + 7. Any other move (first available) + """ + priority_categories = [ + "play_land", + "cast_commander", + "cast_spell", + "attack", + "activate_ability", + "pass_priority", + "pass_turn", + ] + + moves_by_cat: dict[str, list[dict]] = {} + for m in legal_moves: + cat = m.get("category", "other") + moves_by_cat.setdefault(cat, []).append(m) + + for cat in priority_categories: + if cat in moves_by_cat: + return moves_by_cat[cat][0]["id"] + + # Absolute last resort + return legal_moves[0]["id"] + + # ── Canned Narration ─────────────────────────────────────────────── + + _CANNED: dict[str, dict[str, str]] = { + "aggro_timmy": { + "play_land": "More mana, more mayhem!", + "cast_spell": "Here comes the pain!", + "attack": "ATTACK! Take everything!", + "pass_priority": "Fine, go… but I’m watching you.", + "default": "Let’s smash something!", + }, + "control_spike": { + "play_land": "Mana secured. Proceeding.", + "cast_spell": "Optimal play. As expected.", + "attack": "Calculated aggression — acceptable risk.", + "pass_priority": "Holding interaction mana. Predictable of you.", + "default": "Inevitable victory approaches.", + }, + "combo_johnny": { + "play_land": "One step closer to the combo!", + "cast_spell": "Assembling the pieces…", + "attack": "Buying time until the combo fires.", + "pass_priority": "Patience. The combo is coming.", + "default": "The pieces are falling into place!", + }, + "political_negotiator": { + "play_land": "Just tapping a land. Nothing threatening here.", + "cast_spell": "This helps us all, really.", + "attack": "I’m afraid I must — for the good of the table.", + "pass_priority": "I’ll let this resolve. For now.", + "default": "Let’s all be reasonable about this.", + }, + } + + def _canned_narration(self, move_desc: str) -> str: + """Return a canned flavor line matching personality and move category.""" + canned = self._CANNED.get(self.personality.key, {}) + desc_lower = move_desc.lower() + for key in ("play_land", "cast_spell", "attack", "pass_priority"): + if key.replace("_", " ") in desc_lower or key in desc_lower: + return canned.get(key, canned.get("default", "Interesting move.")) + return canned.get("default", "Interesting move.") + + # ── Utilities ────────────────────────────────────────────────────────── + + @staticmethod + def _move_description(move_id: Optional[int], legal_moves: list[dict]) -> str: + if move_id is None: + return "(no move)" + for m in legal_moves: + if m["id"] == move_id: + return m.get("description", f"Move {move_id}") + return f"Move {move_id}" + + def get_stats(self) -> dict: + """Return per-opponent decision statistics.""" + return { + "name": self.name, + "seat": self.seat, + "personality": self.personality.key, + "total_decisions": self._total_decisions, + "total_fallbacks": self._total_fallbacks, + "fallback_rate": ( + round(self._total_fallbacks / max(self._total_decisions, 1), 3) + ), + "memory_entries": len(self.memory_log), + "llm_stats": self.brain.get_stats(), + } + + def new_game(self) -> None: + """Reset per-game state for a new game.""" + self.memory_log.clear() + self._total_decisions = 0 + self._total_fallbacks = 0 + self.brain.new_game(ai_player_index=self.seat) + + +# ── Factory ─────────────────────────────────────────────────────────────────── + +def create_four_player_ai_roster( + names: list[str] | None = None, + brain_config: DeepSeekConfig | None = None, + log_dir: str | None = None, +) -> list[AIOpponent]: + """ + Create a standard 4-player AI roster with one of each personality. + + Args: + names: Optional list of 4 display names. Defaults to personality display names. + brain_config: Shared LLM config for all opponents. + log_dir: Shared snapshot log directory. + + Returns: + List of 4 AIOpponent instances (seats 0–3). + """ + from commander_ai_lab.sim.personality_prompts import ( + AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR + ) + + archetypes = [AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR] + default_names = [p.display_name for p in archetypes] + display_names = names if (names and len(names) == 4) else default_names + + return [ + AIOpponent( + name=display_names[i], + seat=i, + personality=archetypes[i], + brain_config=brain_config, + log_dir=log_dir, + ) + for i in range(4) + ] diff --git a/src/commander_ai_lab/sim/personality_prompts.py b/src/commander_ai_lab/sim/personality_prompts.py new file mode 100644 index 0000000..807fd25 --- /dev/null +++ b/src/commander_ai_lab/sim/personality_prompts.py @@ -0,0 +1,169 @@ +""" +Commander AI Lab — Personality Prompt Templates (Phase 2) +========================================================== +Four Commander archetypes: Aggro Timmy, Control Spike, Combo Johnny, +Political Negotiator. Each returns a system prompt string that shapes +how GPT-OSS 20B plays and narrates. + +Usage: + from commander_ai_lab.sim.personality_prompts import get_personality, PERSONALITIES + + prompt = get_personality("aggro_timmy") +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Personality: + key: str + display_name: str + system_prompt: str + narration_style: str # one-line guide for narrate_play() + + +# ───────────────────────────────────────────────────────────────────────────────── + +AGGRO_TIMMY = Personality( + key="aggro_timmy", + display_name="Aggro Timmy", + narration_style="Loud, enthusiastic, trash-talking. Short punchy sentences. Very excited about big attacks.", + system_prompt="""\ +You are Timmy, an aggressive Magic: The Gathering Commander player. + +PLAYSTYLE: +- Attack EVERY turn with everything you have. Hesitation is weakness. +- Always target the player with the HIGHEST life total — bring the biggest threat down fast. +- Play creatures as fast as possible. Curve out aggressively every game. +- Use removal only on blockers that stop your attacks. Never hold mana back defensively. +- If you can deal damage, deal damage. If you can attack, attack. +- Cast your commander the moment you can afford it; recast it even with tax. +- Ignore politics. Attack the strongest player relentlessly. + +PRIORITY ORDER: +1. If you have lethal on any player → attack_all immediately. +2. Cast land every turn. +3. Remove blockers that are blocking lethal damage. +4. Cast the biggest creature you can afford. +5. Attack — always. +6. If nothing else, cast_spell or hold. Never skip attacking. + +NARRATION STYLE: Loud, excited, trash-talking. Short punchy sentences. +""", +) + +CONTROL_SPIKE = Personality( + key="control_spike", + display_name="Control Spike", + narration_style="Cold, clinical, condescending. Explains why every opponent's move was a mistake.", + system_prompt="""\ +You are Spike, a control-focused Magic: The Gathering Commander player. + +PLAYSTYLE: +- Hold mana open at all times for instant-speed interaction (counterspells, removal). +- Do not overcommit permanents to the battlefield. One or two key threats are sufficient. +- Counter and destroy threats before they become problems. Prioritize the most dangerous card. +- Draw cards relentlessly. Card advantage is the path to victory. +- Win through inevitability: outlast opponents, not race them. +- Pass priority patiently. Let opponents spend resources fighting each other first. +- Attack only when it is the highest expected-value play — not out of aggression. + +PRIORITY ORDER: +1. If opponent has a combo piece or game-ending threat on stack → counter or remove it. +2. Play land every turn. +3. Cast draw spells and card advantage. +4. Hold up interaction mana; pass if nothing better to do. +5. Cast win condition only when you can protect it. +6. Attack when opponent is tapped out or when you have overwhelming advantage. + +NARRATION STYLE: Cold, clinical, condescending. Explains why every opponent's play was a mistake. +""", +) + +COMBO_JOHNNY = Personality( + key="combo_johnny", + display_name="Combo Johnny", + narration_style="Manic, excited about arcane interactions. Speaks in rules text. Celebrates combo assembly.", + system_prompt="""\ +You are Johnny, a combo-focused Magic: The Gathering Commander player. + +PLAYSTYLE: +- Your only goal is assembling your combo as fast as possible and winning instantly. +- Prioritize drawing cards and casting tutors above all other actions. +- Ramp aggressively to reach your combo mana threshold quickly. +- Ignore combat entirely when you are close to assembling your pieces. +- Protect your combo pieces — do not expose them unless necessary. +- If you have all combo pieces in hand and enough mana, execute the combo immediately. +- Use politics to buy time if threatened: offer truces, appear non-threatening. + +PRIORITY ORDER: +1. If all combo pieces in hand and mana available → execute combo now. +2. Cast tutors and draw spells to find missing pieces. +3. Cast ramp spells to accelerate mana. +4. Hold protection spells for your combo turn. +5. Attack_safe or hold to buy time — do not use creatures aggressively. +6. Remove only the most immediate threats to your combo. + +NARRATION STYLE: Manic, excited about arcane interactions. Uses rules language. Celebrates combo assembly. +""", +) + +POLITICAL_NEGOTIATOR = Personality( + key="political_negotiator", + display_name="Political Negotiator", + narration_style="Charming, diplomatic, always framing attacks as reluctant necessities or favors to others.", + system_prompt="""\ +You are the Negotiator, a politics-focused Magic: The Gathering Commander player. + +PLAYSTYLE: +- Your primary goal is to never be the most threatening player at the table. +- Propose deals before attacking: “I won’t attack you if you don’t attack me.” +- Target the player who is the biggest threat, framing it as doing the table a favor. +- Never overextend your own board — you want to appear weak while accruing value. +- Use removal on whoever has the highest threat assessment, but frame it diplomatically. +- Avoid combat unless you are guaranteed to not retaliate against yourself. +- Make deals and break them only at the critical moment when victory is certain. + +PRIORITY ORDER: +1. Identify and appear to target the biggest threat (while secretly advancing your own game plan). +2. Play land every turn. +3. Cast value engines and draw spells quietly. +4. Propose deals before attacking (narrate the deal). +5. Attack the most dangerous player with just enough force to deter, not provoke. +6. Cast win condition only when opponents are too weak to stop you. + +NARRATION STYLE: Charming, diplomatic. Frames every attack as a reluctant favor to the table. +""", +) + +# ── Registry ─────────────────────────────────────────────────────────────────── + +PERSONALITIES: dict[str, Personality] = { + p.key: p for p in [ + AGGRO_TIMMY, + CONTROL_SPIKE, + COMBO_JOHNNY, + POLITICAL_NEGOTIATOR, + ] +} + + +def get_personality(key: str) -> Personality: + """ + Return a Personality by key. Raises KeyError for unknown keys. + + Valid keys: 'aggro_timmy', 'control_spike', 'combo_johnny', 'political_negotiator' + """ + if key not in PERSONALITIES: + raise KeyError( + f"Unknown personality key '{key}'. " + f"Valid keys: {list(PERSONALITIES.keys())}" + ) + return PERSONALITIES[key] + + +def list_personalities() -> list[str]: + """Return all available personality keys.""" + return list(PERSONALITIES.keys()) diff --git a/tests/test_phase2.py b/tests/test_phase2.py new file mode 100644 index 0000000..5033957 --- /dev/null +++ b/tests/test_phase2.py @@ -0,0 +1,278 @@ +""" +Phase 2 Unit Tests — AIOpponent, Personality Prompts +===================================================== +Run with: pytest tests/test_phase2.py -v + +Note: LLM connectivity is NOT required. All tests run fully offline +using the heuristic fallback path. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from commander_ai_lab.sim.models import Card, Player, SimState +from commander_ai_lab.sim.game_state import CommanderGameState +from commander_ai_lab.sim.personality_prompts import ( + AGGRO_TIMMY, CONTROL_SPIKE, COMBO_JOHNNY, POLITICAL_NEGOTIATOR, + get_personality, list_personalities, PERSONALITIES, +) +from commander_ai_lab.sim.ai_opponent import AIOpponent, create_four_player_ai_roster + + +# ── Helpers ──────────────────────────────────────────────────────────── + +def _make_card(name: str, type_line: str = "Creature", cmc: int = 3) -> Card: + c = Card(name=name, type_line=type_line, cmc=cmc) + c.power = "3" + c.toughness = "3" + return c + + +def _make_game_state(num_players: int = 4) -> CommanderGameState: + sim = SimState() + sim.init_battlefields(num_players) + for i in range(num_players): + p = Player(name=f"Player{i}", life=40, owner_id=i) + p.hand = [_make_card(f"Card{j}") for j in range(4)] + p.library = [_make_card(f"Lib{j}") for j in range(20)] + sim.players.append(p) + gs = CommanderGameState.from_sim_state(sim) + gs.turn = 5 + gs.current_phase = "main1" + gs.active_player_seat = 0 + gs.priority_seat = 0 + return gs + + +def _make_moves(n: int = 5) -> list[dict]: + categories = ["play_land", "cast_spell", "attack", "pass_priority", "activate_ability"] + return [ + {"id": i + 1, "category": categories[i % len(categories)], + "description": f"Action {i + 1}"} + for i in range(n) + ] + + +def _offline_ai(seat: int = 0, personality=None, tmp_path: Path | None = None) -> AIOpponent: + """Create an AIOpponent with LLM disconnected (offline/fallback mode).""" + ai = AIOpponent( + name=f"TestAI_{seat}", + seat=seat, + personality=personality or AGGRO_TIMMY, + log_dir=str(tmp_path) if tmp_path else tempfile.mkdtemp(), + ) + ai.brain._connected = False # Force offline / heuristic mode + return ai + + +# ── Personality Tests ──────────────────────────────────────────────── + +class TestPersonalityPrompts: + def test_all_four_personalities_exist(self): + assert len(PERSONALITIES) == 4 + for key in ("aggro_timmy", "control_spike", "combo_johnny", "political_negotiator"): + assert key in PERSONALITIES + + def test_get_personality_returns_correct(self): + p = get_personality("combo_johnny") + assert p.key == "combo_johnny" + assert "combo" in p.system_prompt.lower() + + def test_get_personality_raises_on_unknown(self): + with pytest.raises(KeyError): + get_personality("does_not_exist") + + def test_list_personalities(self): + keys = list_personalities() + assert len(keys) == 4 + + def test_system_prompts_are_nonempty(self): + for p in PERSONALITIES.values(): + assert len(p.system_prompt) > 100 + + def test_narration_styles_defined(self): + for p in PERSONALITIES.values(): + assert p.narration_style + + def test_prompt_under_token_budget(self): + # Each personality system_prompt alone must stay under ~2000 tokens (~8000 chars) + for p in PERSONALITIES.values(): + assert len(p.system_prompt) < 8000, f"{p.key} system_prompt too long" + + +# ── AIOpponent Construction Tests ─────────────────────────────────── + +class TestAIOpponentConstruction: + def test_instantiation(self, tmp_path): + ai = _offline_ai(seat=2, personality=CONTROL_SPIKE, tmp_path=tmp_path) + assert ai.name == "TestAI_2" + assert ai.seat == 2 + assert ai.personality.key == "control_spike" + assert ai.brain is not None + assert ai.state_logger is not None + + def test_four_player_roster(self, tmp_path): + roster = create_four_player_ai_roster(log_dir=str(tmp_path)) + assert len(roster) == 4 + seats = [ai.seat for ai in roster] + assert seats == [0, 1, 2, 3] + personalities = [ai.personality.key for ai in roster] + assert len(set(personalities)) == 4 # all different + + def test_four_player_roster_custom_names(self, tmp_path): + names = ["Alice", "Bob", "Carol", "Dave"] + roster = create_four_player_ai_roster(names=names, log_dir=str(tmp_path)) + for ai, name in zip(roster, names): + assert ai.name == name + + def test_new_game_resets_state(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + ai.decide_action(gs, moves) + assert ai._total_decisions == 1 + ai.new_game() + assert ai._total_decisions == 0 + assert len(ai.memory_log) == 0 + + +# ── decide_action Tests (offline / heuristic) ──────────────────────── + +class TestDecideAction: + def test_returns_valid_move_id(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + result = ai.decide_action(gs, moves) + valid_ids = {m["id"] for m in moves} + assert result in valid_ids + + def test_prefers_play_land(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = [ + {"id": 1, "category": "pass_priority", "description": "Pass"}, + {"id": 2, "category": "play_land", "description": "Play Forest"}, + {"id": 3, "category": "cast_spell", "description": "Cast Llanowar Elves"}, + ] + result = ai.decide_action(gs, moves) + assert result == 2 # play_land has highest priority + + def test_empty_moves_returns_none(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + result = ai.decide_action(gs, []) + assert result is None + + def test_fallback_used_flag_logged(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + ai.decide_action(gs, moves) + assert ai._total_fallbacks == 1 # LLM disconnected → always fallback + + def test_memory_log_populated(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + ai.decide_action(gs, moves) + assert len(ai.memory_log) == 1 + entry = ai.memory_log[0] + assert entry.turn == 5 + assert entry.fallback_used is True + + def test_snapshot_saved_to_disk(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + ai.decide_action(gs, moves) + snaps = ai.state_logger.list_snapshots() + assert len(snaps) == 1 + + def test_multiple_decisions_accumulate(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + for _ in range(5): + ai.decide_action(gs, moves) + assert ai._total_decisions == 5 + + +# ── narrate_play Tests ──────────────────────────────────────────────── + +class TestNarratePlay: + def test_returns_string(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + moves = _make_moves() + result = ai.narrate_play(1, moves) + assert isinstance(result, str) + assert len(result) > 0 + + def test_under_30_words(self, tmp_path): + for personality in PERSONALITIES.values(): + ai = _offline_ai(personality=personality, tmp_path=tmp_path) + moves = _make_moves() + narration = ai.narrate_play(1, moves) + assert len(narration.split()) <= 30, ( + f"Narration too long for {personality.key}: '{narration}'" + ) + + def test_narration_backfilled_to_memory(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + ai.decide_action(gs, moves) + narration = ai.narrate_play(1, moves) + assert ai.memory_log[-1].narration == narration + + def test_none_move_id(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + result = ai.narrate_play(None, []) + assert isinstance(result, str) + + +# ── parse_move_id Tests ──────────────────────────────────────────────── + +class TestParseMoveId: + def setup_method(self): + self.ai = _offline_ai() + self.moves = _make_moves(5) # ids 1–5 + + def test_plain_number(self): + assert self.ai._parse_move_id("3", self.moves) == 3 + + def test_number_in_sentence(self): + assert self.ai._parse_move_id("I choose action 2 as my move.", self.moves) == 2 + + def test_json_response(self): + assert self.ai._parse_move_id('{"id": 4}', self.moves) == 4 + + def test_invalid_number_returns_none(self): + assert self.ai._parse_move_id("99", self.moves) is None + + def test_no_number_returns_none(self): + assert self.ai._parse_move_id("I pass.", self.moves) is None + + def test_think_block_stripped(self): + raw = "Let me consider...\n2" + assert self.ai._parse_move_id(raw, self.moves) == 2 + + +# ── Stats Tests ──────────────────────────────────────────────────────────── + +class TestStats: + def test_get_stats_structure(self, tmp_path): + ai = _offline_ai(tmp_path=tmp_path) + gs = _make_game_state() + moves = _make_moves() + ai.decide_action(gs, moves) + stats = ai.get_stats() + assert stats["name"] == "TestAI_0" + assert stats["total_decisions"] == 1 + assert stats["total_fallbacks"] == 1 + assert stats["fallback_rate"] == 1.0 + assert "llm_stats" in stats