Phase 2: Add ai_opponent.py, personality_prompts.py, tests/test_phase2.py

This commit is contained in:
KoalaTrapLord committed 2026-03-14 16:53:43 -04:00
1 parent d0e2824038
commit 441ba7dd74
3 files changed
+968

No files matched your search

+521
View File
@@ -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())
+278
View File
@@ -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