Phase 2: Add ai_opponent.py, personality_prompts.py, tests/test_phase2.py
This commit is contained in:
1 parent
d0e2824038
commit
441ba7dd74
3 files changed
+968
No files matched your search
@@ -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'<think>.*?</think>', '', 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)
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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 = "<think>Let me consider...</think>\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
|
||||
Reference in new issue
Block a user