Centralize and enhance test stubs to implement the full CommanderGameState interface (battlefield(), living_players(), stack_is_empty(), sim_state, active_player(), priority_player(), to_dict()). Fix all dependent test failures across 12 files: - conftest.py: Enhanced StubCard/StubPlayer/StubGameState with full API compatibility; added basic_creature fixture; fixed fresh_app singleton leak - test_phase3.py: Added battlefield(), active_player(), prompt_builder compat - test_integration.py: Fixed ThreatAssessor import (class→function), added threat_fn dict wrapper, sim_state delegate - test_phase6.py/test_phase8.py: Replaced deprecated get_event_loop() with asyncio.run() - test_benchmark.py: Same async fix + ThreatAssessor functional API - sim/test_engine.py: Removed nonexistent seed= param, use run() method - sim/test_models.py: basic_creature fixture now in conftest - deck_builder/test_models.py: Updated ValidationError regex match - state_logger.py: Added monotonic counter for unique snapshot filenames - ai_opponent.py: Moved _CANNED dict to module-level constant (dataclass fix) - web/app.py: Mount lobby router (fixes 6 LobbyAPI test failures) - test_phase5.py: SessionStore + api._store reset fixture Result: 76 failures + 2 errors → 0 failures, 0 errors (331 passed, 30 skipped) Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""
|
|
Phase 3 Unit Tests — Turn Manager, Threat Assessor
|
|
===================================================
|
|
Run with: pytest tests/test_phase3.py -v
|
|
|
|
All tests run fully offline (no Ollama required). The turn manager is
|
|
tested using a mock game state with stub legal_moves / apply_move,
|
|
avoiding any dependency on the live rules engine.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
from commander_ai_lab.sim.threat_assessor import assess_threats, top_threat, ThreatScore
|
|
from commander_ai_lab.sim.turn_manager import (
|
|
CommanderTurnManager,
|
|
TurnManagerConfig,
|
|
GameEvent,
|
|
PHASES,
|
|
ACTION_PHASES,
|
|
)
|
|
from commander_ai_lab.sim.ai_opponent import AIOpponent
|
|
from commander_ai_lab.sim.personality_prompts import AGGRO_TIMMY, CONTROL_SPIKE
|
|
|
|
|
|
# ── Stub Game State ────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class _StubPlayer:
|
|
name: str
|
|
life: int = 40
|
|
eliminated: bool = False
|
|
hand: list = field(default_factory=list)
|
|
library: list = field(default_factory=list)
|
|
graveyard: list = field(default_factory=list)
|
|
battlefield: list = field(default_factory=list)
|
|
exile: list = field(default_factory=list)
|
|
command_zone: list = field(default_factory=list)
|
|
commander_zone: list = field(default_factory=list)
|
|
commander_cast_count: int = 0
|
|
commander_damage_received: dict = field(default_factory=dict)
|
|
|
|
def commander_tax(self) -> int:
|
|
return self.commander_cast_count * 2
|
|
|
|
class mana_pool:
|
|
"""Stub mana pool."""
|
|
W = U = B = R = G = C = 0
|
|
def total(self) -> int: return 0
|
|
def to_dict(self) -> dict: return {"W":0,"U":0,"B":0,"R":0,"G":0,"C":0,"total":0}
|
|
|
|
mana_pool = mana_pool()
|
|
|
|
|
|
class _StubSimState:
|
|
"""Minimal SimState stub — delegates to player battlefields."""
|
|
|
|
def __init__(self, players: list) -> None:
|
|
self._players = players
|
|
|
|
def get_battlefield(self, seat: int) -> list:
|
|
if 0 <= seat < len(self._players):
|
|
return self._players[seat].battlefield
|
|
return []
|
|
|
|
|
|
class _StubGameState:
|
|
"""Minimal CommanderGameState-compatible stub."""
|
|
|
|
def __init__(self, num_players: int = 4):
|
|
self.players = [_StubPlayer(name=f"P{i}", life=40) for i in range(num_players)]
|
|
self.commander_players = self.players # prompt_builder iterates this
|
|
self.turn = 0
|
|
self.current_phase = "main1"
|
|
self.active_player_seat = 0
|
|
self.priority_seat = 0
|
|
self.land_drop_used = False
|
|
self._move_counter = 0
|
|
self.stack: list = []
|
|
self.sim_state = _StubSimState(self.players)
|
|
|
|
def active_player(self):
|
|
if 0 <= self.active_player_seat < len(self.players):
|
|
return self.players[self.active_player_seat]
|
|
return None
|
|
|
|
def priority_player(self):
|
|
if 0 <= self.priority_seat < len(self.players):
|
|
return self.players[self.priority_seat]
|
|
return None
|
|
|
|
def battlefield(self, seat: int) -> list:
|
|
"""Return the battlefield for the given seat."""
|
|
return self.players[seat].battlefield
|
|
|
|
def stack_is_empty(self) -> bool:
|
|
return len(self.stack) == 0
|
|
|
|
def living_players(self) -> list:
|
|
return [p for p in self.players if not p.eliminated]
|
|
|
|
def get_legal_moves(self, seat: int) -> list[dict]:
|
|
"""Always return one pass move per seat."""
|
|
return [
|
|
{"id": 1, "category": "pass_priority", "description": "Pass"},
|
|
{"id": 2, "category": "play_land", "description": "Play Forest"},
|
|
]
|
|
|
|
def apply_move(self, seat: int, move_id: int) -> None:
|
|
self._move_counter += 1
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"turn": self.turn,
|
|
"current_phase": self.current_phase,
|
|
"active_player_seat": self.active_player_seat,
|
|
"players": [{"name": p.name, "life": p.life} for p in self.players],
|
|
}
|
|
|
|
|
|
def _stub_gs(num_players: int = 4) -> _StubGameState:
|
|
return _StubGameState(num_players=num_players)
|
|
|
|
|
|
def _offline_ai(seat: int, personality=None) -> AIOpponent:
|
|
import tempfile
|
|
ai = AIOpponent(
|
|
name=f"AI_{seat}",
|
|
seat=seat,
|
|
personality=personality or AGGRO_TIMMY,
|
|
log_dir=tempfile.mkdtemp(),
|
|
)
|
|
ai.brain._connected = False
|
|
return ai
|
|
|
|
|
|
def _make_tm(
|
|
num_players: int = 4,
|
|
max_turns: int = 2,
|
|
human_seats: set | None = None,
|
|
) -> CommanderTurnManager:
|
|
gs = _stub_gs(num_players)
|
|
ai_list = [_offline_ai(i) for i in range(num_players)]
|
|
cfg = TurnManagerConfig(max_turns=max_turns, ai_decision_delay=0.0)
|
|
return CommanderTurnManager(
|
|
game_state=gs,
|
|
ai_opponents=ai_list,
|
|
human_seats=human_seats or set(),
|
|
config=cfg,
|
|
)
|
|
|
|
|
|
# ── ThreatAssessor Tests ────────────────────────────────────────────────────
|
|
|
|
class TestThreatAssessor:
|
|
def _gs_with_creatures(self) -> _StubGameState:
|
|
gs = _stub_gs(4)
|
|
# Give seat 0 a big creature — use Card from sim.models for full API compat
|
|
from commander_ai_lab.sim.models import Card
|
|
gs.players[0].battlefield = [
|
|
Card(name="Dragon", type_line="Creature — Dragon", pt="6/6", power="6", toughness="6"),
|
|
Card(name="Forest", type_line="Basic Land — Forest"),
|
|
]
|
|
gs.players[1].hand = [Card(name=f"Card{i}") for i in range(7)]
|
|
return gs
|
|
|
|
def test_returns_score_per_non_eliminated_player(self):
|
|
gs = _stub_gs(4)
|
|
scores = assess_threats(gs, viewer_seat=0)
|
|
assert len(scores) == 4
|
|
|
|
def test_eliminated_players_excluded(self):
|
|
gs = _stub_gs(4)
|
|
gs.players[2].eliminated = True
|
|
scores = assess_threats(gs, viewer_seat=0)
|
|
assert len(scores) == 3
|
|
assert all(s.seat != 2 for s in scores)
|
|
|
|
def test_high_power_creature_raises_threat(self):
|
|
gs = self._gs_with_creatures()
|
|
scores = assess_threats(gs, viewer_seat=1)
|
|
seat0_score = next(s for s in scores if s.seat == 0)
|
|
seat2_score = next(s for s in scores if s.seat == 2)
|
|
assert seat0_score.total > seat2_score.total
|
|
|
|
def test_scores_normalized_0_to_1(self):
|
|
gs = self._gs_with_creatures()
|
|
for score in assess_threats(gs, viewer_seat=0):
|
|
assert 0.0 <= score.total <= 1.0
|
|
|
|
def test_sorted_descending(self):
|
|
gs = self._gs_with_creatures()
|
|
scores = assess_threats(gs, viewer_seat=1)
|
|
totals = [s.total for s in scores]
|
|
assert totals == sorted(totals, reverse=True)
|
|
|
|
def test_top_threat_excludes_self(self):
|
|
gs = self._gs_with_creatures()
|
|
threat = top_threat(gs, viewer_seat=0, exclude_self=True)
|
|
assert threat is not None
|
|
assert threat.seat != 0
|
|
|
|
def test_top_threat_includes_self_when_asked(self):
|
|
gs = self._gs_with_creatures()
|
|
threat = top_threat(gs, viewer_seat=0, exclude_self=False)
|
|
# seat 0 has biggest creature — should be top threat
|
|
assert threat is not None
|
|
assert threat.seat == 0
|
|
|
|
def test_threat_score_has_raw_dict(self):
|
|
gs = _stub_gs()
|
|
for score in assess_threats(gs, viewer_seat=0):
|
|
assert "total_power" in score.raw
|
|
assert "life" in score.raw
|
|
|
|
|
|
# ── TurnManager Construction ────────────────────────────────────────────────
|
|
|
|
class TestTurnManagerConstruction:
|
|
def test_ai_map_built_correctly(self):
|
|
tm = _make_tm(num_players=4)
|
|
assert len(tm._ai_map) == 4
|
|
assert all(seat in tm._ai_map for seat in range(4))
|
|
|
|
def test_turn_queue_initialized(self):
|
|
tm = _make_tm(num_players=4)
|
|
assert list(tm._turn_queue) == [0, 1, 2, 3]
|
|
|
|
def test_human_seats_excluded_from_ai_map(self):
|
|
gs = _stub_gs(4)
|
|
ai_list = [_offline_ai(i) for i in range(1, 4)] # seats 1-3 only
|
|
cfg = TurnManagerConfig(max_turns=1, ai_decision_delay=0.0)
|
|
tm = CommanderTurnManager(
|
|
game_state=gs,
|
|
ai_opponents=ai_list,
|
|
human_seats={0},
|
|
config=cfg,
|
|
)
|
|
assert 0 not in tm._ai_map
|
|
assert 1 in tm._ai_map
|
|
|
|
def test_two_player_game(self):
|
|
tm = _make_tm(num_players=2)
|
|
assert list(tm._turn_queue) == [0, 1]
|
|
|
|
|
|
# ── TurnManager async run_game ──────────────────────────────────────────────
|
|
|
|
class TestRunGame:
|
|
def test_game_completes_within_max_turns(self):
|
|
tm = _make_tm(num_players=4, max_turns=2)
|
|
winner = asyncio.run(tm.run_game())
|
|
# Stub has no elimination logic → game times out, winner by life
|
|
assert tm.game_over is True
|
|
|
|
def test_elimination_removes_from_queue(self):
|
|
tm = _make_tm(num_players=4, max_turns=1)
|
|
# Manually eliminate seat 2 before running
|
|
tm.gs.players[2].eliminated = True
|
|
asyncio.run(tm.run_game())
|
|
assert 2 not in tm._turn_queue
|
|
|
|
def test_events_fired(self):
|
|
events: list[GameEvent] = []
|
|
|
|
async def collect(ev: GameEvent):
|
|
events.append(ev)
|
|
|
|
gs = _stub_gs(2)
|
|
ai_list = [_offline_ai(0), _offline_ai(1)]
|
|
cfg = TurnManagerConfig(max_turns=1, ai_decision_delay=0.0)
|
|
tm = CommanderTurnManager(
|
|
game_state=gs,
|
|
ai_opponents=ai_list,
|
|
config=cfg,
|
|
on_event=collect,
|
|
)
|
|
asyncio.run(tm.run_game())
|
|
types = {e.event_type for e in events}
|
|
assert "phase_change" in types
|
|
|
|
def test_thinking_callbacks_fired(self):
|
|
thinking_log: list[tuple] = []
|
|
|
|
async def on_think(seat: int, is_thinking: bool):
|
|
thinking_log.append((seat, is_thinking))
|
|
|
|
gs = _stub_gs(2)
|
|
ai_list = [_offline_ai(0), _offline_ai(1)]
|
|
cfg = TurnManagerConfig(max_turns=1, ai_decision_delay=0.0)
|
|
tm = CommanderTurnManager(
|
|
game_state=gs, ai_opponents=ai_list, config=cfg, on_thinking=on_think
|
|
)
|
|
asyncio.run(tm.run_game())
|
|
# Each AI turn should emit (seat, True) and (seat, False)
|
|
assert len(thinking_log) > 0
|
|
starts = [(s, t) for s, t in thinking_log if t]
|
|
ends = [(s, t) for s, t in thinking_log if not t]
|
|
assert len(starts) == len(ends)
|
|
|
|
def test_get_stats_structure(self):
|
|
tm = _make_tm(num_players=2, max_turns=1)
|
|
asyncio.run(tm.run_game())
|
|
stats = tm.get_stats()
|
|
assert "turn" in stats
|
|
assert "total_actions" in stats
|
|
assert "game_over" in stats
|
|
|
|
|
|
# ── APNAP Priority ──────────────────────────────────────────────────────────
|
|
|
|
class TestAPNAP:
|
|
def test_phases_constant_has_all_required_phases(self):
|
|
for phase in ("untap", "draw", "main1", "main2",
|
|
"declare_attackers", "cleanup"):
|
|
assert phase in PHASES
|
|
|
|
def test_action_phases_subset_of_phases(self):
|
|
assert ACTION_PHASES.issubset(set(PHASES))
|
|
|
|
def test_apnap_does_not_deadlock(self):
|
|
"""APNAP window should complete even if all AI pass immediately."""
|
|
tm = _make_tm(num_players=4, max_turns=1)
|
|
async def run():
|
|
await tm._apnap_priority_window(
|
|
active_seat=0, turn_num=1, phase="main1"
|
|
)
|
|
asyncio.run(run()) # Should not hang
|
|
|
|
|
|
# ── Threat Score Refresh ────────────────────────────────────────────────────
|
|
|
|
class TestThreatRefresh:
|
|
def test_threat_cache_populated_after_main1(self):
|
|
tm = _make_tm(num_players=4, max_turns=1)
|
|
asyncio.run(tm.run_game())
|
|
# After at least one main1 phase, cache should have entries
|
|
assert len(tm._threat_cache) > 0
|
|
|
|
def test_threat_scores_per_seat_are_lists(self):
|
|
tm = _make_tm(num_players=4, max_turns=1)
|
|
asyncio.run(tm.run_game())
|
|
for seat, scores in tm._threat_cache.items():
|
|
assert isinstance(scores, list)
|
|
for s in scores:
|
|
assert isinstance(s, ThreatScore)
|