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