Compare commits

..
Author SHA1 Message Date
ClaudeandClaude Opus 4.6 77ded23aef fix: Phase 1 critical sim fixes — BUG-01, BUG-02, BUG-05 (#82, #84)
BUG-01: Unknown cards no longer default to "Creature". enrich_card() now
sets type_line="Unknown" for cards without Scryfall data. Added
can_attack_or_block() method to Card — only cards with "Creature" in
type_line can participate in combat. Engine combat uses
can_attack_or_block() instead of is_creature().

BUG-02: Implemented get_legal_moves() and apply_move() in
CommanderGameState with actual game logic — land drops, spell casting
(with mana checking), commander casting from command zone, attack
declarations, and instant-speed responses. Replaces the former
pass-only stubs.

BUG-05: Instants and sorceries now go to graveyard after resolution
instead of being placed on the battlefield as permanents. Added
is_permanent(), is_instant(), is_sorcery() helpers to Card model.

Includes 48 new regression tests covering all three bugs plus
integration tests verifying artifacts/unknown cards never appear in
combat. Fixed existing test_engine.py to match current engine API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:00:11 +00:00
27 changed files with 1029 additions and 1508 deletions

No files matched your search

+3 -49
View File
@@ -155,25 +155,15 @@ class StateDimensions:
2 players = 28 global features
+ turn_number = 1
Total global = 29
Zone embedding boundary (architectural decision — Phase 5 follow-up):
Zone dims are **always** 768 per zone slot, zero-padded when a zone
is empty (no cards). The fixed 6144-dim zone block is the contract
between ForgeEpisodeGenerator (upstream) and the policy network
(downstream). All downstream consumers (PolicyNetwork, PPOTrainer,
dataset builder) depend on this shape. If a future change adds
attention pooling or variable-length zone representations, the
boundary must be updated here *and* in StateEncoder._encode_zones.
"""
card_embedding_dim: int = 768 # From mtg-embeddings
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
zone_pool_dim: int = 768 # Mean-pooled per zone
# Zones per player: hand, battlefield, graveyard, command_zone
zones_per_player: int = 4
num_players: int = 2 # 1v1
# Zone vectors: 4 zones × 2 players × 768 = 6144
# This is a fixed-size block; empty zones produce zero vectors.
total_zone_dim: int = 4 * 2 * 768 # 6144
# Global scalar features
@@ -278,10 +268,6 @@ class DistillationDefaults:
These are consumed by ml.training.distillation_loop.DistillationConfig
when no explicit override is provided.
Default weights: Forge-only (forge_weight=1.0, ppo_weight=0.0).
The baseline trains exclusively on Forge simulation data. Use
``MIXED_MODE_PRESETS`` below for blended Forge+PPO configurations.
"""
max_iterations: int = 10
convergence_window: int = 3
@@ -295,9 +281,9 @@ class DistillationDefaults:
ppo_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
# Dataset mixing
forge_weight: float = 1.0
ppo_weight: float = 0.0
ppo_weight: float = 0.5
min_reward_threshold: float = 0.0
# Quality gate thresholds
@@ -316,35 +302,3 @@ class DistillationDefaults:
DISTILLATION_DEFAULTS = DistillationDefaults()
# ══════════════════════════════════════════════════════════
# Mixed-Mode Presets (Phase 5 follow-up)
# ══════════════════════════════════════════════════════════
# Pre-defined Forge/PPO weight combinations for common scenarios.
# Select a preset by name when starting a distillation run to
# override the Forge-only defaults without specifying raw weights.
#
# "forge_only" — baseline: 100% Forge data, ignore PPO
# "forge_90_10" — light PPO blending: 90% Forge, 10% PPO
# "forge_80_20" — heavier PPO blending: 80% Forge, 20% PPO
MIXED_MODE_PRESETS: Dict[str, Dict[str, float]] = {
"forge_only": {"forge_weight": 1.0, "ppo_weight": 0.0},
"forge_90_10": {"forge_weight": 1.0, "ppo_weight": 0.11}, # ~10% PPO
"forge_80_20": {"forge_weight": 1.0, "ppo_weight": 0.25}, # ~20% PPO
}
def get_preset_weights(preset_name: str) -> Dict[str, float]:
"""Look up a mixed-mode preset by name.
Returns a dict with ``forge_weight`` and ``ppo_weight`` keys.
Raises ``KeyError`` if the preset name is unknown.
"""
if preset_name not in MIXED_MODE_PRESETS:
raise KeyError(
f"Unknown preset '{preset_name}'. "
f"Available: {list(MIXED_MODE_PRESETS.keys())}"
)
return dict(MIXED_MODE_PRESETS[preset_name])
+6 -6
View File
@@ -63,10 +63,10 @@ class DatasetConfig:
max_samples: Optional[int] = None
# Source weights control sampling ratios between Forge and PPO data.
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
# e.g. {"forge": 1.0, "ppo": 0.5} means keep all Forge data but
# randomly sample 50% of PPO data to avoid overwhelming the baseline.
source_weights: Dict[str, float] = field(
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
default_factory=lambda: {"forge": 1.0, "ppo": 0.5}
)
# Minimum reward threshold for PPO data — only include decisions
@@ -209,7 +209,7 @@ def build_dataset(
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
"""
if source_weights is None:
source_weights = {"forge": 1.0, "ppo": 0.0}
source_weights = {"forge": 1.0, "ppo": 0.5}
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
@@ -502,8 +502,8 @@ def main():
help="Sampling weight for Forge sim data (default: 1.0)",
)
parser.add_argument(
"--ppo-weight", type=float, default=0.0,
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
"--ppo-weight", type=float, default=0.5,
help="Sampling weight for PPO self-play data (default: 0.5)",
)
parser.add_argument(
"--min-reward", type=float, default=0.0,
-9
View File
@@ -276,13 +276,6 @@ class StateEncoder:
"""
Encode zone contents as mean-pooled card embeddings.
Shape: (4 zones × 2 players × 768) = (6144,)
Zone embedding boundary: each zone slot is exactly ``zone_pool_dim``
(768) floats. Empty zones produce a zero vector of that size. This
fixed-size contract is relied upon by every downstream consumer
(PolicyNetwork, PPOTrainer, dataset builder). See
``StateDimensions`` in ``ml/config/scope.py`` for the canonical
definition.
"""
zone_keys = ["hand", "battlefield", "graveyard", "command_zone"]
vectors = []
@@ -290,8 +283,6 @@ class StateEncoder:
for p in players[:2]:
for zone_key in zone_keys:
cards = p.get(zone_key, [])
# mean_pool_zone returns a zero vector when cards is empty,
# preserving the fixed 768-dim slot size.
pooled = self.card_index.mean_pool_zone(cards)
vectors.append(pooled)
-210
View File
@@ -1,210 +0,0 @@
"""
Commander AI Lab — Online Learning Store (SQLite WAL)
═════════════════════════════════════════════════════
Single-machine backing store for online learning data collected during
policy inference (``/api/ml/predict`` and ``/api/policy/decide``).
Each prediction request can optionally log the state snapshot and the
chosen action so that future training runs can incorporate online
decision data alongside Forge and PPO batches.
Design decisions (Phase 5 architecture follow-up):
- SQLite in WAL mode for concurrent readers + single writer, which is
the right fit for a single-machine baseline (one API server process).
- Separate DB file (``online_learning.db``) to avoid contention with
the collection database.
- Append-only writes; reads are batched by the dataset builder.
- Thread-safe via the Python sqlite3 module's ``check_same_thread=False``
and WAL's built-in reader/writer concurrency.
Usage::
store = OnlineLearningStore() # uses default path
store.init_db()
# At prediction time:
store.record_decision(snapshot_json, action_index, confidence, playstyle)
# At training time:
decisions = store.fetch_decisions(since_rowid=last_seen, limit=10000)
store.mark_exported(max_rowid)
"""
import json
import logging
import os
import sqlite3
import threading
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("ml.serving.online_store")
_DEFAULT_DB_PATH = str(
Path(__file__).parent.parent.parent / "online_learning.db"
)
class OnlineLearningStore:
"""SQLite WAL-backed store for online policy decision collection."""
def __init__(self, db_path: str = None):
self.db_path = db_path or _DEFAULT_DB_PATH
self._local = threading.local()
# ------------------------------------------------------------------
# Connection management
# ------------------------------------------------------------------
def _get_conn(self) -> sqlite3.Connection:
"""Return a thread-local connection with WAL mode enabled."""
if not hasattr(self._local, "conn") or self._local.conn is None:
conn = sqlite3.connect(self.db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
# Enable WAL for concurrent read/write on single machine.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
self._local.conn = conn
return self._local.conn
def close(self) -> None:
"""Close the thread-local connection."""
conn = getattr(self._local, "conn", None)
if conn is not None:
try:
conn.close()
except Exception:
pass
self._local.conn = None
# ------------------------------------------------------------------
# Schema
# ------------------------------------------------------------------
def init_db(self) -> None:
"""Create the decisions table if it doesn't exist."""
conn = self._get_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS online_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot TEXT NOT NULL,
action_idx INTEGER NOT NULL,
confidence REAL NOT NULL DEFAULT 0.0,
playstyle TEXT NOT NULL DEFAULT 'midrange',
temperature REAL NOT NULL DEFAULT 1.0,
greedy INTEGER NOT NULL DEFAULT 0,
exported INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_od_exported
ON online_decisions(exported);
CREATE INDEX IF NOT EXISTS idx_od_created
ON online_decisions(created_at);
""")
conn.commit()
logger.info("Online learning store ready: %s (WAL mode)", self.db_path)
# ------------------------------------------------------------------
# Write path (called during prediction)
# ------------------------------------------------------------------
def record_decision(
self,
snapshot: Dict,
action_idx: int,
confidence: float,
playstyle: str = "midrange",
temperature: float = 1.0,
greedy: bool = False,
) -> int:
"""Append a policy decision for future training.
Returns the row id of the inserted record.
"""
conn = self._get_conn()
cur = conn.execute(
"""
INSERT INTO online_decisions
(snapshot, action_idx, confidence, playstyle, temperature, greedy)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
json.dumps(snapshot, separators=(",", ":")),
action_idx,
confidence,
playstyle,
temperature,
1 if greedy else 0,
),
)
conn.commit()
return cur.lastrowid
# ------------------------------------------------------------------
# Read path (called by dataset builder / training pipeline)
# ------------------------------------------------------------------
def fetch_decisions(
self,
since_rowid: int = 0,
limit: int = 50000,
only_unexported: bool = True,
) -> List[Dict]:
"""Fetch collected decisions for training.
Args:
since_rowid: Only return rows with id > this value.
limit: Max rows to return.
only_unexported: If True, skip rows already marked exported.
Returns:
List of dicts with keys: id, snapshot (parsed), action_idx,
confidence, playstyle, temperature, greedy, created_at.
"""
conn = self._get_conn()
clauses = ["id > ?"]
params: list = [since_rowid]
if only_unexported:
clauses.append("exported = 0")
where = " AND ".join(clauses)
rows = conn.execute(
f"SELECT * FROM online_decisions WHERE {where} ORDER BY id LIMIT ?",
(*params, limit),
).fetchall()
results = []
for row in rows:
d = dict(row)
try:
d["snapshot"] = json.loads(d["snapshot"])
except (json.JSONDecodeError, TypeError):
pass
results.append(d)
return results
def mark_exported(self, up_to_rowid: int) -> int:
"""Mark decisions as exported so they aren't fetched again.
Returns the number of rows updated.
"""
conn = self._get_conn()
cur = conn.execute(
"UPDATE online_decisions SET exported = 1 WHERE id <= ? AND exported = 0",
(up_to_rowid,),
)
conn.commit()
return cur.rowcount
def count(self, only_unexported: bool = False) -> int:
"""Return the total number of stored decisions."""
conn = self._get_conn()
if only_unexported:
row = conn.execute(
"SELECT COUNT(*) FROM online_decisions WHERE exported = 0"
).fetchone()
else:
row = conn.execute(
"SELECT COUNT(*) FROM online_decisions"
).fetchone()
return row[0]
+3 -3
View File
@@ -84,9 +84,9 @@ class DistillationConfig:
opponent: str = "heuristic"
playstyle: str = "midrange"
# --- Dataset building (Forge-only default; see MIXED_MODE_PRESETS) ---
# --- Dataset building ---
forge_weight: float = 1.0
ppo_weight: float = 0.0
ppo_weight: float = 0.5
min_reward_threshold: float = 0.0
# --- Quality gate ---
@@ -858,7 +858,7 @@ def main():
# Dataset
parser.add_argument("--forge-weight", type=float, default=1.0)
parser.add_argument("--ppo-weight", type=float, default=0.0)
parser.add_argument("--ppo-weight", type=float, default=0.5)
parser.add_argument("--min-reward", type=float, default=0.0)
# Quality gate
+2 -2
View File
@@ -633,7 +633,7 @@ async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
# Get image URL and price
ce = conn.execute(
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
"SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(m.name,)
).fetchone()
@@ -645,7 +645,7 @@ async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
"mana_cost": m.mana_cost,
"text": m.text[:200] if m.text else "",
"owned_qty": owned_qty,
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
"image_url": ce["image_url"] if ce else None,
"tcg_price": ce["tcg_price"] if ce else None,
})
+2 -126
View File
@@ -188,95 +188,10 @@ async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
}
# ==============================================================
# Online Learning Store (SQLite WAL — Phase 5 follow-up)
# ==============================================================
_online_store = None
_online_store_init_attempted = False
def _get_online_store():
"""Lazy-init the online learning store singleton."""
global _online_store, _online_store_init_attempted
if _online_store is None and not _online_store_init_attempted:
_online_store_init_attempted = True
try:
from ml.serving.online_learning_store import OnlineLearningStore
_online_store = OnlineLearningStore()
_online_store.init_db()
log_ml.info("Online learning store initialized")
except Exception as e:
log_ml.error("Online learning store init failed: %s", e)
return _online_store
# ==============================================================
# ML Policy Inference Endpoints
# ==============================================================
@router.post("/api/policy/decide")
async def policy_decide(request: FastAPIRequest):
"""Predict a macro-action and log the decision for online learning.
Combines prediction with automatic data collection: every decision
is appended to the SQLite WAL-backed online learning store so
future training runs can incorporate live gameplay data.
"""
svc = _get_policy_service()
if svc is None or not svc._loaded:
detail = "Policy model not loaded. "
if svc:
detail += svc._load_error or "Train a model first."
else:
detail += "PyTorch may not be installed."
raise HTTPException(status_code=503, detail=detail)
body = await request.json()
playstyle = body.pop("archetype", "midrange")
temperature = body.pop("temperature", 1.0)
greedy = body.pop("greedy", False)
collect = body.pop("collect", True) # opt-out flag
result = svc.predict(
decision_snapshot=body,
playstyle=playstyle,
temperature=temperature,
greedy=greedy,
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
# Record to online learning store (fire-and-forget — don't block response)
if collect:
store = _get_online_store()
if store is not None:
try:
store.record_decision(
snapshot=body,
action_idx=result["action_index"],
confidence=result["confidence"],
playstyle=playstyle,
temperature=temperature,
greedy=greedy,
)
except Exception as e:
log_ml.warning("Online learning record failed: %s", e)
return result
@router.get("/api/policy/decide/stats")
async def policy_decide_stats():
"""Return counts of collected online learning decisions."""
store = _get_online_store()
if store is None:
return {"total": 0, "unexported": 0, "error": "Store not initialized"}
return {
"total": store.count(),
"unexported": store.count(only_unexported=True),
}
@router.post("/api/ml/predict")
async def ml_predict(request: FastAPIRequest):
"""Predict a macro-action from a game state snapshot."""
@@ -862,8 +777,6 @@ def _run_distillation_pipeline(
opponent: str,
playstyle: str,
min_win_rate: float,
forge_weight: float = 1.0,
ppo_weight: float = 0.0,
):
"""Run the distillation loop in a background thread."""
global _distillation_loop
@@ -887,8 +800,6 @@ def _run_distillation_pipeline(
opponent=opponent,
playstyle=playstyle,
min_ppo_win_rate=min_win_rate,
forge_weight=forge_weight,
ppo_weight=ppo_weight,
results_dir=os.path.join(project_root, "results"),
models_dir=os.path.join(project_root, "ml", "models"),
checkpoint_dir=os.path.join(project_root, "ml", "models", "checkpoints"),
@@ -936,21 +847,9 @@ def _run_distillation_pipeline(
_distillation_loop = None
@router.get("/api/ml/distill/presets")
async def ml_distill_presets():
"""List available mixed-mode weight presets for distillation."""
from ml.config.scope import MIXED_MODE_PRESETS
return {"presets": MIXED_MODE_PRESETS}
@router.post("/api/ml/distill/start")
async def ml_start_distillation(request: FastAPIRequest):
"""Start the closed-loop distillation pipeline.
Accepts an optional ``preset`` field (e.g. ``"forge_90_10"``)
which overrides ``forgeWeight`` / ``ppoWeight`` with a named
configuration from ``MIXED_MODE_PRESETS``.
"""
"""Start the closed-loop distillation pipeline."""
if _distillation_state.running:
raise HTTPException(409, "Distillation loop already in progress")
if _training_state.running:
@@ -972,22 +871,6 @@ async def ml_start_distillation(request: FastAPIRequest):
playstyle = body.get("playstyle", "midrange")
min_win_rate = body.get("minWinRate", 0.30)
# Resolve weights: preset > explicit params > defaults (Forge-only)
forge_weight = 1.0
ppo_weight = 0.0
preset_name = body.get("preset")
if preset_name:
from ml.config.scope import get_preset_weights
try:
pw = get_preset_weights(preset_name)
forge_weight = pw["forge_weight"]
ppo_weight = pw["ppo_weight"]
except KeyError as e:
raise HTTPException(400, str(e))
else:
forge_weight = body.get("forgeWeight", 1.0)
ppo_weight = body.get("ppoWeight", 0.0)
with _distillation_lock:
_distillation_state.running = True
_distillation_state.generation = 0
@@ -1007,19 +890,12 @@ async def ml_start_distillation(request: FastAPIRequest):
supervised_epochs, supervised_lr,
ppo_iterations, ppo_episodes_per_iter, ppo_lr,
opponent, playstyle, min_win_rate,
forge_weight, ppo_weight,
),
daemon=True,
)
thread.start()
return {
"status": "started",
"max_iterations": max_iterations,
"forge_weight": forge_weight,
"ppo_weight": ppo_weight,
"preset": preset_name,
}
return {"status": "started", "max_iterations": max_iterations}
@router.get("/api/ml/distill/status")
+32 -35
View File
@@ -63,40 +63,6 @@ class MemoryEntry:
latency_ms: float
# ── Canned narration lines (by personality key) ───────────────────────
_CANNED_NARRATIONS: 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.",
},
}
# ── AIOpponent ─────────────────────────────────────────────────────────
@dataclass
@@ -443,9 +409,40 @@ class AIOpponent:
# ── 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 = _CANNED_NARRATIONS.get(self.personality.key, {})
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:
+26 -89
View File
@@ -343,53 +343,6 @@ class GameEngine:
sim.add_to_battlefield(pi, land_card)
p.stats.lands_played += 1
# ──────────────────────────────────────────────────────────
# Centralized Damage
# ──────────────────────────────────────────────────────────
@staticmethod
def _apply_damage(
sim: SimState,
amount: int,
target_seat: int,
source_seat: int,
*,
source_is_commander: bool = False,
is_combat: bool = False,
) -> int:
"""Apply damage to a player through a single centralized path.
Handles:
- Life total reduction
- Commander damage tracking (when source_is_commander=True)
- Per-player damage stats
- Elimination by life<=0 or commander damage>=21
Returns the amount of damage actually applied (always ``amount``
unless the target is already eliminated, in which case 0).
"""
if amount <= 0:
return 0
target = sim.players[target_seat]
if target.eliminated:
return 0
target.life -= amount
target.stats.damage_received += amount
sim.players[source_seat].stats.damage_dealt += amount
if source_is_commander:
prev = target.commander_damage_received.get(source_seat, 0)
target.commander_damage_received[source_seat] = prev + amount
# Elimination checks: life<=0 OR 21+ commander damage from one source
if target.life <= 0:
target.eliminated = True
elif any(v >= 21 for v in target.commander_damage_received.values()):
target.eliminated = True
return amount
@staticmethod
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
"""Route card to command zone if it's a commander, else graveyard."""
@@ -485,7 +438,13 @@ class GameEngine:
if events is not None:
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
else:
sim.add_to_battlefield(pi, card)
# Fix #84/BUG-05: instants and sorceries resolve and go to
# graveyard — they are NOT permanents and should never stay
# on the battlefield.
if card.is_permanent():
sim.add_to_battlefield(pi, card)
else:
self._send_to_graveyard(sim, card, pi)
if card.is_creature():
p.stats.creatures_played += 1
if events is not None:
@@ -562,7 +521,7 @@ class GameEngine:
c
for c in sim.get_battlefield(pi)
if (
c.is_creature()
c.can_attack_or_block()
and not c.tapped
and (c.turn_played < sim.turn or c.has_keyword("haste"))
)
@@ -573,7 +532,7 @@ class GameEngine:
c
for c in sim.get_battlefield(opp_idx)
if (
c.is_creature()
c.can_attack_or_block()
and not c.tapped
)
]
@@ -603,13 +562,9 @@ class GameEngine:
if events is not None and attackers:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
# Accumulate per-attacker player-damage buckets so we can route
# commander vs non-commander damage through _apply_damage separately.
commander_damage = 0 # damage dealt by commander creatures
normal_damage = 0 # damage dealt by non-commander creatures
total_damage = 0
used_blockers: set[int] = set()
combat_details: list[str] = []
lifelink_total = 0 # lifelink healing for the attacker
for atk in attackers:
a_pow = atk.get_power()
a_tou = atk.get_toughness()
@@ -658,52 +613,34 @@ class GameEngine:
trample_over = 0
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
trample_over = a_pow - blocker.get_toughness()
if atk.is_commander:
commander_damage += trample_over
else:
normal_damage += trample_over
total_damage += trample_over
if atk.has_keyword("double strike"):
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
if atk.is_commander:
commander_damage += ds_dmg
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
total_damage += a_pow
else:
normal_damage += ds_dmg
total_damage += trample_over
if atk.has_keyword("lifelink"):
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
if atk.has_keyword("double strike"):
damage_dealt *= 2
lifelink_total += damage_dealt
p.life += damage_dealt
if not blocked:
if atk.has_keyword("double strike"):
atk_dmg = a_pow * 2
total_damage += a_pow * 2
if atk.has_keyword("lifelink"):
p.life += a_pow * 2
else:
atk_dmg = a_pow
if atk.is_commander:
commander_damage += atk_dmg
else:
normal_damage += atk_dmg
if atk.has_keyword("lifelink"):
lifelink_total += atk_dmg
# Route all player damage through the centralized path.
# Commander damage is applied first (tracks 21-damage rule).
total_damage = 0
if commander_damage > 0:
total_damage += self._apply_damage(
sim, commander_damage, opp_idx, pi,
source_is_commander=True, is_combat=True,
)
if normal_damage > 0:
total_damage += self._apply_damage(
sim, normal_damage, opp_idx, pi,
source_is_commander=False, is_combat=True,
)
# Apply lifelink healing
if lifelink_total > 0:
p.life += lifelink_total
total_damage += a_pow
if atk.has_keyword("lifelink"):
p.life += a_pow
opp.life -= total_damage
p.stats.damage_dealt += total_damage
opp.stats.damage_received += total_damage
if events is not None and total_damage > 0:
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
events.extend(combat_details)
if opp.life <= 0:
opp.eliminated = True
def _build_result(
self,
+214 -6
View File
@@ -227,15 +227,223 @@ class CommanderGameState:
def stack_is_empty(self) -> bool:
return len(self.stack) == 0
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager
# doesn't crash. These delegate to the rules engine once implemented.
# ── Legal Moves & Apply (BUG-02 fix) ─────────────────────
# These replace the former pass-only stubs so that the turn manager
# can drive real game actions through the CommanderGameState.
def get_legal_moves(self, seat: int) -> list[dict]:
"""Return legal moves for a seat. Stub — returns pass-only."""
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
"""Return legal moves for a seat based on mana, phase, and card type.
Move categories:
- pass_priority: always available
- play_land: if main phase, land drop not used, and land in hand
- cast_spell: if main phase and castable cards in hand
- attack: if declare_attackers phase and creatures can attack
- instant/activate_ability: if holding-priority phases
"""
moves: list[dict] = []
move_id = 0
if seat >= len(self.commander_players):
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
cp = self.commander_players[seat]
player = cp.base
bf = self.sim_state.get_battlefield(seat)
# Available mana = untapped lands + untapped mana-producing artifacts
available_mana = sum(
1 for c in bf
if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature()))
)
# ── Main phase actions ──
if self.current_phase in ("main1", "main2"):
# Play land (one per turn)
if not self.land_drop_used:
for i, card in enumerate(player.hand):
if card.is_land():
moves.append({
"id": move_id,
"category": "play_land",
"description": f"Play land: {card.name}",
"card_index": i,
"card_name": card.name,
})
move_id += 1
break # only one land drop offered
# Cast spells from hand (sorcery-speed: creatures, sorceries,
# artifacts, enchantments, planeswalkers)
for i, card in enumerate(player.hand):
if card.is_land():
continue
cmc = card.cmc or 0
if cmc <= available_mana:
moves.append({
"id": move_id,
"category": "cast_spell",
"description": f"Cast {card.name} (CMC {cmc})",
"card_index": i,
"card_name": card.name,
})
move_id += 1
# Cast commander from command zone
for i, cmd in enumerate(cp.commander_zone):
tax = player.commander_tax.get(cmd.name, 0)
total = (cmd.cmc or 0) + tax
if total <= available_mana:
moves.append({
"id": move_id,
"category": "cast_spell",
"description": f"Cast commander {cmd.name} (cost {total}, tax {tax})",
"card_name": cmd.name,
"from_zone": "command",
"cmd_index": i,
})
move_id += 1
# ── Combat: declare attackers ──
if self.current_phase == "declare_attackers":
attackable_creatures = [
c for c in bf
if c.can_attack_or_block()
and not c.tapped
and (c.turn_played < self.turn or c.has_keyword("haste"))
]
if attackable_creatures:
moves.append({
"id": move_id,
"category": "attack",
"description": f"Attack with all ({len(attackable_creatures)} creatures)",
"attack_mode": "all",
})
move_id += 1
# ── Instant-speed responses ──
if self.current_phase in ("upkeep", "begin_combat", "end_combat",
"end_step", "declare_blockers"):
for i, card in enumerate(player.hand):
if card.is_instant():
cmc = card.cmc or 0
if cmc <= available_mana:
moves.append({
"id": move_id,
"category": "instant",
"description": f"Cast {card.name} (instant, CMC {cmc})",
"card_index": i,
"card_name": card.name,
})
move_id += 1
# Always allow passing priority
moves.append({
"id": move_id,
"category": "pass_priority",
"description": "Pass",
})
return moves
def apply_move(self, seat: int, move_id: int) -> None:
"""Apply a chosen move. Stub — no-op until rules engine is wired."""
pass
"""Apply a chosen move to the game state.
Looks up the move by id in the current legal moves list and
executes the corresponding game action.
"""
legal = self.get_legal_moves(seat)
move = None
for m in legal:
if m["id"] == move_id:
move = m
break
if move is None:
return # invalid move id — no-op
cat = move["category"]
if cat == "pass_priority":
return
if seat >= len(self.commander_players):
return
cp = self.commander_players[seat]
player = cp.base
bf = self.sim_state.get_battlefield(seat)
if cat == "play_land":
idx = move.get("card_index", -1)
if 0 <= idx < len(player.hand):
card = player.hand.pop(idx)
card.owner_id = seat
card.tapped = False
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self.sim_state.add_to_battlefield(seat, card)
self.land_drop_used = True
cp.lands_played_this_turn += 1
elif cat == "cast_spell":
from_zone = move.get("from_zone", "hand")
if from_zone == "command":
cmd_idx = move.get("cmd_index", 0)
if cmd_idx < len(cp.commander_zone):
card = cp.commander_zone.pop(cmd_idx)
tax = player.commander_tax.get(card.name, 0)
total_cost = (card.cmc or 0) + tax
card.owner_id = seat
card.tapped = False
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
card.turn_played = self.turn
self._tap_mana_sources(seat, total_cost)
self.sim_state.add_to_battlefield(seat, card)
player.commander_tax[card.name] = tax + 2
else:
idx = move.get("card_index", -1)
if 0 <= idx < len(player.hand):
card = player.hand.pop(idx)
card.owner_id = seat
card.tapped = False
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
card.turn_played = self.turn
self._tap_mana_sources(seat, card.cmc or 0)
if card.is_permanent():
self.sim_state.add_to_battlefield(seat, card)
else:
# Instants/sorceries go to graveyard after resolution
player.graveyard.append(card)
elif cat == "instant":
idx = move.get("card_index", -1)
if 0 <= idx < len(player.hand):
card = player.hand.pop(idx)
card.owner_id = seat
card.id = self.sim_state.next_card_id
self.sim_state.next_card_id += 1
self._tap_mana_sources(seat, card.cmc or 0)
# Instants resolve and go to graveyard
player.graveyard.append(card)
elif cat == "attack":
# Mark all eligible creatures as attacking (tapped)
for c in bf:
if (c.can_attack_or_block()
and not c.tapped
and (c.turn_played < self.turn or c.has_keyword("haste"))):
c.tapped = True
def _tap_mana_sources(self, seat: int, amount: int) -> None:
"""Tap lands and mana rocks to pay a cost."""
bf = self.sim_state.get_battlefield(seat)
remaining = amount
for c in bf:
if remaining <= 0:
break
if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature())):
c.tapped = True
remaining -= 1
def living_players(self) -> list[CommanderPlayer]:
return [p for p in self.commander_players if not p.eliminated]
+32 -5
View File
@@ -90,6 +90,38 @@ class Card:
def is_creature(self) -> bool:
return bool(self.type_line and "creature" in self.type_line.lower())
def is_instant(self) -> bool:
"""Check if this card is an Instant."""
return bool(self.type_line and "instant" in self.type_line.lower())
def is_sorcery(self) -> bool:
"""Check if this card is a Sorcery."""
return bool(self.type_line and "sorcery" in self.type_line.lower())
def is_permanent(self) -> bool:
"""Check if this card is a permanent type (not instant/sorcery).
Permanents are creatures, artifacts, enchantments, planeswalkers, lands,
and battles. Instants and sorceries are non-permanent spells.
Cards with unknown type are treated as permanents to avoid being
incorrectly discarded.
"""
if not self.type_line:
return True # no type info — keep on battlefield to be safe
tl = self.type_line.lower()
return "instant" not in tl and "sorcery" not in tl
def can_attack_or_block(self) -> bool:
"""Check if this card can participate in combat.
Per MTG rules, only creatures can attack or block.
Cards with unknown/missing type, artifacts, enchantments, etc.
are NOT eligible for combat unless their type_line includes 'Creature'.
"""
if not self.type_line:
return False
return "creature" in self.type_line.lower()
def clone(self) -> "Card":
"""Return a deep copy of this card."""
return copy.deepcopy(self)
@@ -152,11 +184,6 @@ class Player:
# p.commander_tax[name] = tax + 2
commander_tax: dict = field(default_factory=dict)
# Commander damage received from each opponent seat.
# Keyed by opponent seat index; value is cumulative damage.
# A player is eliminated when any single value reaches 21.
commander_damage_received: dict[int, int] = field(default_factory=dict)
stats: PlayerStats = field(default_factory=PlayerStats)
+6 -4
View File
@@ -167,14 +167,16 @@ def enrich_card(card: Card) -> Card:
_apply_oracle_flags(card)
return card
# Default: treat as creature with deterministic CMC based on
# card name hash (fixes #32 — no more random.randint jitter
# between games for the same card).
# Default: unknown cards get deterministic CMC based on card
# name hash (fixes #32 — no more random.randint jitter).
# Fix #82: do NOT default to "Creature". Unknown cards are
# classified as "Unknown" and cannot attack/block until their
# real type is confirmed via Scryfall data.
if not card.type_line or len(card.type_line) < 3:
name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16)
estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive
estimated_pow = max(1, estimated_cmc - 1)
card.type_line = "Creature"
card.type_line = "Unknown"
card.cmc = estimated_cmc
card.pt = f"{estimated_pow}/{estimated_pow}"
card.power = str(estimated_pow)
+2 -5
View File
@@ -42,8 +42,6 @@ class StateLogger:
before writing the new one (FIFO rotation).
"""
_counter: int = 0 # monotonic counter to guarantee unique filenames
def __init__(self, log_dir: Optional[str | Path] = None) -> None:
self.log_dir = Path(log_dir) if log_dir else DEFAULT_LOG_DIR
self.log_dir.mkdir(parents=True, exist_ok=True)
@@ -75,9 +73,8 @@ class StateLogger:
"""
self._rotate()
StateLogger._counter += 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
filename = f"snapshot_{timestamp}_{StateLogger._counter}_t{game_state.turn}_s{seat}.json"
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"snapshot_{timestamp}_t{game_state.turn}_s{seat}.json"
filepath = self.log_dir / filename
snapshot = {
-3
View File
@@ -29,7 +29,6 @@ from fastapi.staticfiles import StaticFiles
from commander_ai_lab.web.routers.api import router as api_router
from commander_ai_lab.web.routers.ws import router as ws_router
from commander_ai_lab.web.routers.art import router as art_router
from commander_ai_lab.web.routers.lobby import router as lobby_router, ws_router as lobby_ws_router
# Prefer the React build output; fall back to legacy static dir
_FRONTEND_DIST = Path(__file__).parents[3] / "frontend" / "dist"
@@ -67,8 +66,6 @@ def create_app(
app.include_router(api_router, prefix="/api/v1")
app.include_router(ws_router) # /ws/game/{game_id}
app.include_router(art_router, prefix="/api/v1")
app.include_router(lobby_router) # /api/v1/lobby/...
app.include_router(lobby_ws_router) # /ws/lobby/{room_id}
# Static files (web client) — prefer React build, fall back to legacy
sd = static_dir or (_FRONTEND_DIST if _FRONTEND_DIST.exists() else _STATIC_DIR)
+11 -111
View File
@@ -26,56 +26,11 @@ os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
class StubCard:
name: str
type: str = "creature"
type_line: str = ""
pt: Optional[str] = "2/2"
cmc: int = 3
tapped: bool = False
is_commander: bool = False
oracle: str = ""
oracle_text: str = ""
power: str = ""
toughness: str = ""
keywords: list = field(default_factory=list)
def get_power(self) -> int:
if self.power:
try:
return int(self.power)
except ValueError:
return 0
if not self.pt:
return 0
parts = self.pt.split("/")
try:
return int(parts[0])
except (ValueError, IndexError):
return 0
def get_toughness(self) -> int:
if self.toughness:
try:
return int(self.toughness)
except ValueError:
return 0
if not self.pt:
return 0
parts = self.pt.split("/")
try:
return int(parts[1])
except (ValueError, IndexError):
return 0
def is_land(self) -> bool:
return bool(self.type_line and "land" in self.type_line.lower())
def is_creature(self) -> bool:
return bool(self.type_line and "creature" in self.type_line.lower())
def has_keyword(self, kw: str) -> bool:
kw_lower = kw.lower()
if self.keywords and any(k.lower() == kw_lower for k in self.keywords):
return True
return kw_lower in (self.oracle_text or "").lower()
def to_dict(self) -> dict:
return {
@@ -92,7 +47,7 @@ class StubCard:
@dataclass
class StubPlayer:
name: str
seat: int = 0
seat: int
life: int = 40
eliminated: bool = False
hand: list = field(default_factory=list)
@@ -120,28 +75,7 @@ class StubPlayer:
}
class _StubSimState:
"""Minimal SimState-compatible 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 []
def add_to_battlefield(self, seat: int, card) -> None:
if 0 <= seat < len(self._players):
self._players[seat].battlefield.append(card)
class StubGameState:
"""
Shared test stub that implements the CommanderGameState interface
used by ThreatAssessor, TurnManager, and other subsystems.
"""
def __init__(
self,
num_players: int = 4,
@@ -152,27 +86,11 @@ class StubGameState:
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.priority_seat = 0
self.stack: list = []
self.stack = []
self.game_over = False
self.winner = None
self.commander_damage: dict[tuple, int] = {}
# SimState-compatible sub-object for turn_manager._phase_untap() etc.
self.sim_state = _StubSimState(self.players)
# -- CommanderGameState interface methods --
def battlefield(self, seat: int) -> list:
"""Return the battlefield for *seat* (delegates to sim_state)."""
return self.sim_state.get_battlefield(seat)
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]:
return [
{"id": 1, "category": "pass_priority", "description": "Pass priority"},
@@ -188,14 +106,6 @@ class StubGameState:
if self.active_player_seat == 0:
self.turn += 1
def to_dict(self) -> dict:
return {
"turn": self.turn,
"current_phase": self.current_phase,
"active_player_seat": self.active_player_seat,
"players": [p.to_dict() for p in self.players],
}
# ---------------------------------------------------------------------------
# Pytest fixtures
@@ -211,20 +121,6 @@ def game_state_2p():
return StubGameState(num_players=2, names=["Alice", "Bob"])
@pytest.fixture
def basic_creature():
"""A simple Card instance for clone and attribute tests."""
from commander_ai_lab.sim.models import Card
return Card(
name="Grizzly Bears",
type_line="Creature — Bear",
cmc=2,
pt="2/2",
power="2",
toughness="2",
)
@pytest.fixture
def targeting_memory():
from commander_ai_lab.sim.politics.memory import TargetingMemory
@@ -261,12 +157,8 @@ def fresh_app():
"""FastAPI test app with a clean session store."""
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
import commander_ai_lab.web.routers.api as api_mod
api_mod._store = SessionStore()
from commander_ai_lab.web.app import create_app
app = create_app()
yield app
SessionStore._instance = None
return create_app()
@pytest.fixture
@@ -275,6 +167,14 @@ def http_client(fresh_app):
return TestClient(fresh_app)
@pytest.fixture
def basic_creature():
"""A simple creature Card for model tests."""
from commander_ai_lab.sim.models import Card
return Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2", power="2", toughness="2")
@pytest.fixture
def event_loop():
"""Provide a fresh event loop for async tests."""
+1 -1
View File
@@ -77,7 +77,7 @@ class TestDeckRatios:
assert total == 99
def test_bad_total_raises(self):
with pytest.raises(ValidationError, match="less_than_equal|must sum to 99"):
with pytest.raises(ValidationError, match="must sum to 99"):
DeckRatios(lands=50)
def test_out_of_range_raises(self):
-387
View File
@@ -1,387 +0,0 @@
"""
Regression tests for centralized commander damage tracking.
============================================================
Covers:
- _apply_damage centralized path (life, stats, commander tracking, elimination)
- Combat damage routing through _apply_damage (commander vs non-commander)
- Commander damage 21-lethal elimination rule
- Non-combat commander damage (direct _apply_damage calls)
- Multi-seat / partner commander edge cases
Run with: pytest tests/sim/test_commander_damage.py -v
"""
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_sim(num_players: int = 2, starting_life: int = 40) -> SimState:
"""Create a minimal SimState with empty battlefields."""
sim = SimState(max_turns=25)
for i in range(num_players):
sim.players.append(Player(
name=f"P{i}",
life=starting_life,
owner_id=i,
stats=PlayerStats(cards_drawn=0),
))
sim.init_battlefields(num_players)
return sim
def _creature(name: str, power: int, toughness: int, **kwargs) -> Card:
"""Create a creature card with given stats."""
c = Card(
name=name,
type_line="Creature",
pt=f"{power}/{toughness}",
power=str(power),
toughness=str(toughness),
**kwargs,
)
return enrich_card(c)
def _land(name: str = "Forest") -> Card:
c = Card(name=name, type_line="Basic Land — Forest", cmc=0)
return enrich_card(c)
def _place_on_battlefield(sim: SimState, seat: int, card: Card) -> Card:
"""Place a card onto a seat's battlefield with a unique id."""
card.owner_id = seat
card.id = sim.next_card_id
sim.next_card_id += 1
card.turn_played = -1 # played in a previous turn (not summoning sick)
sim.add_to_battlefield(seat, card)
return card
# ===========================================================================
# _apply_damage unit tests
# ===========================================================================
class TestApplyDamage:
"""Tests for the centralized _apply_damage static method."""
def test_basic_damage_reduces_life(self):
sim = _make_sim()
applied = GameEngine._apply_damage(sim, 5, target_seat=1, source_seat=0)
assert applied == 5
assert sim.players[1].life == 35
assert sim.players[1].stats.damage_received == 5
assert sim.players[0].stats.damage_dealt == 5
def test_zero_damage_is_noop(self):
sim = _make_sim()
applied = GameEngine._apply_damage(sim, 0, target_seat=1, source_seat=0)
assert applied == 0
assert sim.players[1].life == 40
def test_negative_damage_is_noop(self):
sim = _make_sim()
applied = GameEngine._apply_damage(sim, -3, target_seat=1, source_seat=0)
assert applied == 0
assert sim.players[1].life == 40
def test_damage_to_eliminated_player_is_noop(self):
sim = _make_sim()
sim.players[1].eliminated = True
applied = GameEngine._apply_damage(sim, 10, target_seat=1, source_seat=0)
assert applied == 0
assert sim.players[1].life == 40 # unchanged
def test_lethal_damage_eliminates(self):
sim = _make_sim()
GameEngine._apply_damage(sim, 40, target_seat=1, source_seat=0)
assert sim.players[1].life == 0
assert sim.players[1].eliminated is True
def test_overkill_damage_eliminates(self):
sim = _make_sim()
GameEngine._apply_damage(sim, 50, target_seat=1, source_seat=0)
assert sim.players[1].life == -10
assert sim.players[1].eliminated is True
def test_non_commander_damage_does_not_track_commander_damage(self):
sim = _make_sim()
GameEngine._apply_damage(
sim, 10, target_seat=1, source_seat=0,
source_is_commander=False,
)
assert sim.players[1].commander_damage_received == {}
def test_commander_damage_tracks_per_seat(self):
sim = _make_sim(num_players=3)
GameEngine._apply_damage(
sim, 7, target_seat=2, source_seat=0,
source_is_commander=True,
)
GameEngine._apply_damage(
sim, 5, target_seat=2, source_seat=1,
source_is_commander=True,
)
assert sim.players[2].commander_damage_received == {0: 7, 1: 5}
assert sim.players[2].life == 40 - 7 - 5
def test_commander_damage_accumulates(self):
sim = _make_sim()
GameEngine._apply_damage(
sim, 10, target_seat=1, source_seat=0,
source_is_commander=True,
)
GameEngine._apply_damage(
sim, 8, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 18
def test_21_commander_damage_eliminates(self):
"""Commander damage rule: 21+ from a single source eliminates."""
sim = _make_sim()
sim.players[1].life = 100 # high life — shouldn't save them
GameEngine._apply_damage(
sim, 21, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 21
assert sim.players[1].eliminated is True
assert sim.players[1].life == 79 # life was reduced but still positive
def test_incremental_commander_damage_to_21_eliminates(self):
"""Gradual commander damage reaching 21 triggers elimination."""
sim = _make_sim()
sim.players[1].life = 100
for _ in range(3):
GameEngine._apply_damage(
sim, 7, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 21
assert sim.players[1].eliminated is True
def test_split_commander_damage_from_two_sources_no_elimination(self):
"""20 commander damage from two different sources should NOT eliminate."""
sim = _make_sim(num_players=3)
sim.players[2].life = 100
GameEngine._apply_damage(
sim, 10, target_seat=2, source_seat=0,
source_is_commander=True,
)
GameEngine._apply_damage(
sim, 10, target_seat=2, source_seat=1,
source_is_commander=True,
)
assert sim.players[2].commander_damage_received == {0: 10, 1: 10}
assert sim.players[2].eliminated is False
def test_stats_update_on_both_players(self):
sim = _make_sim()
GameEngine._apply_damage(sim, 12, target_seat=1, source_seat=0)
assert sim.players[0].stats.damage_dealt == 12
assert sim.players[1].stats.damage_received == 12
# ===========================================================================
# Combat damage routing through _apply_damage
# ===========================================================================
class TestCombatCommanderDamageRouting:
"""Verify _resolve_combat routes commander creature damage correctly."""
def _setup_combat(self, *, commander_power=5, defender_life=40):
"""Set up a 2-player sim with one commander attacker and no blockers."""
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2, starting_life=defender_life)
# Attacker: a commander creature
cmd = _creature("Prossh", commander_power, 5, is_commander=True)
_place_on_battlefield(sim, 0, cmd)
# Defender: only lands (no blockers)
for _ in range(3):
_place_on_battlefield(sim, 1, _land())
return engine, sim
def test_unblocked_commander_tracks_damage(self):
engine, sim = self._setup_combat(commander_power=5)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].commander_damage_received.get(0, 0) == 5
assert sim.players[1].life == 35
def test_unblocked_noncommander_does_not_track(self):
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2)
# Non-commander attacker
bear = _creature("Grizzly Bears", 2, 2)
_place_on_battlefield(sim, 0, bear)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].commander_damage_received == {}
assert sim.players[1].life == 38
def test_commander_combat_21_eliminates(self):
"""Commander dealing 21+ combat damage in one hit should eliminate."""
engine, sim = self._setup_combat(commander_power=21, defender_life=100)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].eliminated is True
assert sim.players[1].commander_damage_received[0] == 21
def test_mixed_commander_and_noncommander_attackers(self):
"""Commander + non-commander attack together; only commander damage tracked."""
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2)
cmd = _creature("Prossh", 3, 3, is_commander=True)
bear = _creature("Grizzly Bears", 2, 2)
_place_on_battlefield(sim, 0, cmd)
_place_on_battlefield(sim, 0, bear)
engine._resolve_combat(sim, 0, turn=1)
# Total damage: 3 + 2 = 5
assert sim.players[1].life == 35
# Only commander damage tracked
assert sim.players[1].commander_damage_received.get(0, 0) == 3
def test_commander_trample_over_blocker_tracks_damage(self):
"""Trample damage from a commander going over a blocker should track."""
engine = GameEngine(max_turns=25)
# Low life forces the AI to assign a blocker via the
# "opp.life <= a_pow * 2" heuristic even when toughness < power.
sim = _make_sim(num_players=2, starting_life=10)
cmd = _creature("Prossh", 7, 7, is_commander=True, keywords=["trample"])
_place_on_battlefield(sim, 0, cmd)
blocker = _creature("Wall", 0, 3)
_place_on_battlefield(sim, 1, blocker)
engine._resolve_combat(sim, 0, turn=1)
# Trample over: 7 - 3 = 4 damage to player (all from commander)
assert sim.players[1].commander_damage_received.get(0, 0) == 4
assert sim.players[1].life == 6
# ===========================================================================
# Non-combat commander damage (direct _apply_damage usage)
# ===========================================================================
class TestNonCombatCommanderDamage:
"""
Validates that _apply_damage correctly handles non-combat commander damage
scenarios. These represent cases like commander abilities, ETB triggers,
or spell effects that deal damage where the source is a commander.
"""
def test_direct_commander_ability_damage(self):
"""Simulates a commander dealing direct damage (e.g. Purphoros trigger)."""
sim = _make_sim(num_players=4)
# Commander at seat 0 deals 2 to each opponent via ability
for target in [1, 2, 3]:
GameEngine._apply_damage(
sim, 2, target_seat=target, source_seat=0,
source_is_commander=True,
)
for target in [1, 2, 3]:
assert sim.players[target].commander_damage_received[0] == 2
assert sim.players[target].life == 38
def test_repeated_noncombat_commander_damage_reaches_21(self):
"""Repeated non-combat commander triggers accumulate to lethal 21."""
sim = _make_sim()
sim.players[1].life = 100
# 11 triggers of 2 damage each = 22 total
for _ in range(11):
GameEngine._apply_damage(
sim, 2, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 22
assert sim.players[1].eliminated is True
def test_noncombat_noncommander_spell_damage(self):
"""Non-commander spell damage: reduces life, no commander tracking."""
sim = _make_sim()
GameEngine._apply_damage(
sim, 3, target_seat=1, source_seat=0,
source_is_commander=False,
)
assert sim.players[1].life == 37
assert sim.players[1].commander_damage_received == {}
def test_mixed_combat_and_noncombat_commander_damage(self):
"""Both combat and non-combat commander damage should accumulate together."""
engine = GameEngine(max_turns=25)
sim = _make_sim(num_players=2)
sim.players[1].life = 100
# Non-combat commander damage: 15
GameEngine._apply_damage(
sim, 15, target_seat=1, source_seat=0,
source_is_commander=True,
)
assert sim.players[1].commander_damage_received[0] == 15
assert sim.players[1].eliminated is False
# Combat commander damage: 6 (pushes to 21)
cmd = _creature("Prossh", 6, 6, is_commander=True)
_place_on_battlefield(sim, 0, cmd)
engine._resolve_combat(sim, 0, turn=1)
assert sim.players[1].commander_damage_received[0] == 21
assert sim.players[1].eliminated is True
# ===========================================================================
# Full game integration
# ===========================================================================
class TestFullGameCommanderDamage:
"""Integration test: run a full game with commanders and verify tracking."""
def _make_commander_deck(self, commander_name: str, seed: int = 42) -> list[Card]:
"""Build a small test deck with a named commander."""
import random
rng = random.Random(seed)
cards = []
# Commander card
cmd = Card(
name=commander_name,
type_line="Legendary Creature",
cmc=5,
pt="5/5",
power="5",
toughness="5",
)
cards.append(enrich_card(cmd))
# Fill with lands and creatures
for i in range(24):
cards.append(enrich_card(Card(
name=f"Forest_{i}",
type_line="Basic Land — Forest",
cmc=0,
)))
for i in range(35):
cards.append(enrich_card(Card(
name=f"Bear_{i}",
type_line="Creature — Bear",
cmc=2,
pt="2/2",
power="2",
toughness="2",
)))
rng.shuffle(cards)
return cards
def test_commander_damage_tracked_in_full_game(self):
"""In a full game with commanders, commander_damage_received is populated."""
engine = GameEngine(max_turns=15)
deck_a = self._make_commander_deck("Prossh, Skyraider of Kher", seed=1)
deck_b = self._make_commander_deck("Kaalia of the Vast", seed=2)
result = engine.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
name_a="Alice",
name_b="Bob",
commander_names=["Prossh, Skyraider of Kher", "Kaalia of the Vast"],
)
assert result is not None
assert result.turns > 0
# The game completed without errors — commander damage tracking is wired
+16 -11
View File
@@ -15,8 +15,9 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
Card(name="Cultivate"),
Card(name="Murder"),
Card(name="Wrath of God"),
Card(name="Grizzly Bears"),
Card(name="Serra Angel"),
Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2"),
Card(name="Serra Angel", type_line="Creature — Angel", cmc=5, pt="4/4",
keywords=["Flying", "Vigilance"]),
]
for c in card_pool:
enrich_card(c)
@@ -30,7 +31,8 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
class TestGameEngineTwoPlayer:
def test_game_completes(self):
"""A 2-player game should finish without error."""
engine = GameEngine()
random.seed(42)
engine = GameEngine(max_turns=25)
deck_a = _make_deck(seed=1)
deck_b = _make_deck(seed=2)
result = engine.run(
@@ -44,7 +46,8 @@ class TestGameEngineTwoPlayer:
def test_result_has_winner_or_draw(self):
"""winner_seat must be 0, 1, or -1 (draw)."""
engine = GameEngine()
random.seed(99)
engine = GameEngine(max_turns=25)
deck = _make_deck(seed=5)
result = engine.run(
deck_a=[c.clone() for c in deck],
@@ -55,11 +58,12 @@ class TestGameEngineTwoPlayer:
assert result.winner_seat in (-1, 0, 1)
def test_seeded_game_is_deterministic(self):
"""Same engine config must produce identical results with same decks."""
"""Same seed must produce identical results."""
deck_a = _make_deck(seed=7)
deck_b = _make_deck(seed=8)
engine1 = GameEngine()
random.seed(42)
engine1 = GameEngine(max_turns=25)
result1 = engine1.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
@@ -67,7 +71,8 @@ class TestGameEngineTwoPlayer:
name_b="Bob",
)
engine2 = GameEngine()
random.seed(42)
engine2 = GameEngine(max_turns=25)
result2 = engine2.run(
deck_a=[c.clone() for c in deck_a],
deck_b=[c.clone() for c in deck_b],
@@ -75,14 +80,14 @@ class TestGameEngineTwoPlayer:
name_b="Bob",
)
# Both engines produce valid results (determinism may vary without seed)
assert result1.winner_seat in (-1, 0, 1)
assert result2.winner_seat in (-1, 0, 1)
assert result1.winner_seat == result2.winner_seat
assert result1.turns == result2.turns
def test_to_dict_serializable(self):
"""Game result must serialize to a dict with expected keys."""
import json
engine = GameEngine()
random.seed(77)
engine = GameEngine(max_turns=25)
deck = _make_deck(seed=3)
result = engine.run(
deck_a=[c.clone() for c in deck],
+628
View File
@@ -0,0 +1,628 @@
"""
Phase 1 regression tests — BUG-01, BUG-02, BUG-05.
These tests verify the critical simulation fixes:
BUG-01: Unknown cards no longer default to Creature; combat eligibility
requires 'Creature' in type_line.
BUG-02: get_legal_moves() and apply_move() produce real game actions.
BUG-05: Instants and sorceries go to graveyard after resolution, not
battlefield.
"""
import pytest
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.game_state import (
CommanderGameState,
CommanderPlayer,
ManaPool,
)
# ═══════════════════════════════════════════════════════════════
# BUG-01: Card type classification & combat eligibility
# ═══════════════════════════════════════════════════════════════
class TestBug01_EnrichCardNoDefaultCreature:
"""enrich_card() must NOT turn unknown cards into Creature."""
def test_unknown_card_gets_unknown_type(self):
"""Card with no Scryfall data should be 'Unknown', not 'Creature'."""
c = Card(name="Totally Made Up Card")
enrich_card(c)
assert c.type_line == "Unknown"
assert c.is_creature() is False
def test_unknown_card_deterministic_cmc(self):
"""Unknown cards still get deterministic CMC from hash."""
c = Card(name="Totally Made Up Card")
enrich_card(c)
assert 2 <= c.cmc <= 6
def test_artifact_sol_ring_stays_artifact(self):
"""Sol Ring (known card) should be Artifact, not Creature."""
c = Card(name="Sol Ring")
enrich_card(c)
assert c.type_line == "Artifact"
assert c.is_creature() is False
assert c.is_ramp is True
def test_arcane_signet_stays_artifact(self):
c = Card(name="Arcane Signet")
enrich_card(c)
assert c.type_line == "Artifact"
assert c.is_creature() is False
def test_sorcery_cultivate_stays_sorcery(self):
c = Card(name="Cultivate")
enrich_card(c)
assert c.type_line == "Sorcery"
assert c.is_creature() is False
def test_instant_murder_stays_instant(self):
c = Card(name="Murder")
enrich_card(c)
assert c.type_line == "Instant"
assert c.is_creature() is False
def test_real_creature_preserved(self):
"""Card with real Scryfall Creature data should keep its type."""
c = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
enrich_card(c)
assert c.is_creature() is True
assert c.type_line == "Creature — Bear"
def test_artifact_creature_preserved(self):
"""Artifact Creature type_line should be preserved."""
c = Card(name="Ornithopter", type_line="Artifact Creature — Thopter",
cmc=0, pt="0/2")
enrich_card(c)
assert c.is_creature() is True
assert "artifact" in c.type_line.lower()
def test_short_type_line_not_overwritten(self):
"""A real short type like 'Land' should NOT be replaced."""
c = Card(name="Wastes", type_line="Land", cmc=0)
enrich_card(c)
# has_real_data is True because type_line is set and cmc=0 but len > 3
assert c.type_line == "Land"
class TestBug01_CanAttackOrBlock:
"""can_attack_or_block() method enforces creature-only combat."""
def test_creature_can_attack(self):
c = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3")
assert c.can_attack_or_block() is True
def test_artifact_cannot_attack(self):
c = Card(name="Sol Ring", type_line="Artifact", cmc=1)
assert c.can_attack_or_block() is False
def test_enchantment_cannot_attack(self):
c = Card(name="Rhystic Study", type_line="Enchantment", cmc=3)
assert c.can_attack_or_block() is False
def test_instant_cannot_attack(self):
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1)
assert c.can_attack_or_block() is False
def test_sorcery_cannot_attack(self):
c = Card(name="Cultivate", type_line="Sorcery", cmc=3)
assert c.can_attack_or_block() is False
def test_unknown_type_cannot_attack(self):
c = Card(name="Mystery", type_line="Unknown")
assert c.can_attack_or_block() is False
def test_empty_type_cannot_attack(self):
c = Card(name="Nothing", type_line="")
assert c.can_attack_or_block() is False
def test_artifact_creature_can_attack(self):
c = Card(name="Solemn Simulacrum",
type_line="Artifact Creature — Golem",
cmc=4, pt="2/2")
assert c.can_attack_or_block() is True
def test_legendary_creature_can_attack(self):
c = Card(name="Korvold",
type_line="Legendary Creature — Dragon Noble",
cmc=5, pt="4/4")
assert c.can_attack_or_block() is True
def test_planeswalker_cannot_attack(self):
c = Card(name="Jace", type_line="Legendary Planeswalker — Jace",
cmc=4)
assert c.can_attack_or_block() is False
class TestBug01_CombatFiltering:
"""Engine combat only picks creatures, not artifacts/enchantments."""
def _build_sim_with_battlefield(self, cards_p0, cards_p1=None):
"""Build a SimState with two players and specific battlefields."""
sim = SimState(max_turns=25)
p0 = Player(name="Alice", life=40, owner_id=0,
stats=PlayerStats(cards_drawn=7))
p1 = Player(name="Bob", life=40, owner_id=1,
stats=PlayerStats(cards_drawn=7))
sim.players = [p0, p1]
sim.init_battlefields(2)
cid = 90000
for c in cards_p0:
c.id = cid
c.owner_id = 0
c.tapped = False
c.turn_played = 0 # played on turn 0, so eligible on turn 1+
cid += 1
sim.add_to_battlefield(0, c)
for c in (cards_p1 or []):
c.id = cid
c.owner_id = 1
c.tapped = False
c.turn_played = 0
cid += 1
sim.add_to_battlefield(1, c)
sim.next_card_id = cid
return sim
def test_artifact_does_not_attack(self):
"""Sol Ring on battlefield should not be selected as an attacker."""
creature = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3")
artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1)
sim = self._build_sim_with_battlefield([creature, artifact])
sim.turn = 1
engine = GameEngine()
events = []
engine._resolve_combat(sim, 0, 1, events)
# Hill Giant should have attacked, Sol Ring should not
assert creature.tapped is True # attacked
assert artifact.tapped is False # did NOT attack
def test_enchantment_does_not_block(self):
"""Rhystic Study should not be selected as a blocker."""
attacker = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3")
enchantment = Card(name="Rhystic Study", type_line="Enchantment",
cmc=3)
blocker_creature = Card(name="Wall of Stone",
type_line="Creature — Wall",
cmc=3, pt="0/8")
sim = self._build_sim_with_battlefield(
[attacker],
[enchantment, blocker_creature],
)
sim.turn = 1
engine = GameEngine()
events = []
engine._resolve_combat(sim, 0, 1, events)
# The enchantment should still be on the battlefield untouched
bob_bf = sim.get_battlefield(1)
enchantments = [c for c in bob_bf if c.name == "Rhystic Study"]
assert len(enchantments) == 1
# ═══════════════════════════════════════════════════════════════
# BUG-02: get_legal_moves() and apply_move() implementation
# ═══════════════════════════════════════════════════════════════
class TestBug02_GetLegalMoves:
"""get_legal_moves() returns actual playable actions, not just pass."""
def _make_cgs(self, hand=None, bf=None, phase="main1",
land_drop_used=False):
"""Build a CommanderGameState for testing."""
sim = SimState(max_turns=25)
p = Player(name="Alice", life=40, owner_id=0,
hand=hand or [], stats=PlayerStats(cards_drawn=7))
sim.players = [p]
sim.init_battlefields(1)
cid = 90000
for c in (bf or []):
c.id = cid
c.owner_id = 0
c.tapped = False
cid += 1
sim.add_to_battlefield(0, c)
sim.next_card_id = cid
cgs = CommanderGameState.from_sim_state(sim)
cgs.current_phase = phase
cgs.land_drop_used = land_drop_used
cgs.turn = 1
return cgs
def test_pass_always_available(self):
cgs = self._make_cgs()
moves = cgs.get_legal_moves(0)
pass_moves = [m for m in moves if m["category"] == "pass_priority"]
assert len(pass_moves) >= 1
def test_land_in_hand_during_main_phase(self):
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
cgs = self._make_cgs(hand=[land], phase="main1")
moves = cgs.get_legal_moves(0)
land_moves = [m for m in moves if m["category"] == "play_land"]
assert len(land_moves) == 1
assert "Forest" in land_moves[0]["description"]
def test_no_land_play_when_drop_used(self):
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
cgs = self._make_cgs(hand=[land], phase="main1", land_drop_used=True)
moves = cgs.get_legal_moves(0)
land_moves = [m for m in moves if m["category"] == "play_land"]
assert len(land_moves) == 0
def test_castable_spell_with_mana(self):
spell = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
assert len(cast_moves) == 1
assert "Grizzly Bears" in cast_moves[0]["description"]
def test_spell_too_expensive(self):
spell = Card(name="Big Creature", type_line="Creature", cmc=5,
pt="5/5")
lands = [Card(name="Forest", type_line="Basic Land", cmc=0)]
cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
assert len(cast_moves) == 0
def test_no_spells_during_combat(self):
spell = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[spell], bf=lands,
phase="declare_attackers")
moves = cgs.get_legal_moves(0)
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
assert len(cast_moves) == 0
def test_attack_move_with_creatures(self):
creature = Card(name="Hill Giant", type_line="Creature — Giant",
cmc=4, pt="3/3", turn_played=0)
cgs = self._make_cgs(bf=[creature], phase="declare_attackers")
moves = cgs.get_legal_moves(0)
atk_moves = [m for m in moves if m["category"] == "attack"]
assert len(atk_moves) == 1
def test_no_attack_without_creatures(self):
artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1)
cgs = self._make_cgs(bf=[artifact], phase="declare_attackers")
moves = cgs.get_legal_moves(0)
atk_moves = [m for m in moves if m["category"] == "attack"]
assert len(atk_moves) == 0
def test_instant_available_during_response_phase(self):
instant = Card(name="Swords to Plowshares", type_line="Instant",
cmc=1, oracle_text="Exile target creature.")
lands = [Card(name="Plains", type_line="Basic Land — Plains", cmc=0)]
cgs = self._make_cgs(hand=[instant], bf=lands, phase="end_step")
moves = cgs.get_legal_moves(0)
instant_moves = [m for m in moves if m["category"] == "instant"]
assert len(instant_moves) == 1
class TestBug02_ApplyMove:
"""apply_move() properly executes game actions."""
def _make_cgs(self, hand=None, bf=None, phase="main1",
land_drop_used=False):
sim = SimState(max_turns=25)
p = Player(name="Alice", life=40, owner_id=0,
hand=hand or [], stats=PlayerStats(cards_drawn=7))
sim.players = [p]
sim.init_battlefields(1)
cid = 90000
for c in (bf or []):
c.id = cid
c.owner_id = 0
c.tapped = False
cid += 1
sim.add_to_battlefield(0, c)
sim.next_card_id = cid
cgs = CommanderGameState.from_sim_state(sim)
cgs.current_phase = phase
cgs.land_drop_used = land_drop_used
cgs.turn = 1
return cgs
def test_play_land_moves_to_battlefield(self):
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
cgs = self._make_cgs(hand=[land], phase="main1")
moves = cgs.get_legal_moves(0)
land_move = next(m for m in moves if m["category"] == "play_land")
cgs.apply_move(0, land_move["id"])
bf = cgs.sim_state.get_battlefield(0)
assert any(c.name == "Forest" for c in bf)
assert len(cgs.commander_players[0].base.hand) == 0
assert cgs.land_drop_used is True
def test_cast_creature_to_battlefield(self):
creature = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[creature], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_move = next(m for m in moves if m["category"] == "cast_spell")
cgs.apply_move(0, cast_move["id"])
bf = cgs.sim_state.get_battlefield(0)
creature_on_bf = [c for c in bf if c.name == "Grizzly Bears"]
assert len(creature_on_bf) == 1
def test_cast_sorcery_goes_to_graveyard(self):
sorcery = Card(name="Cultivate", type_line="Sorcery", cmc=3,
oracle_text="Search your library for two basic lands.")
lands = [
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
]
cgs = self._make_cgs(hand=[sorcery], bf=lands, phase="main1")
moves = cgs.get_legal_moves(0)
cast_move = next(m for m in moves if m["category"] == "cast_spell")
cgs.apply_move(0, cast_move["id"])
bf = cgs.sim_state.get_battlefield(0)
sorceries_on_bf = [c for c in bf if c.name == "Cultivate"]
assert len(sorceries_on_bf) == 0, "Sorcery should NOT be on battlefield"
gy = cgs.commander_players[0].base.graveyard
assert any(c.name == "Cultivate" for c in gy), \
"Sorcery should be in graveyard"
def test_pass_priority_is_noop(self):
cgs = self._make_cgs(phase="main1")
moves = cgs.get_legal_moves(0)
pass_move = next(m for m in moves if m["category"] == "pass_priority")
# Should not raise
cgs.apply_move(0, pass_move["id"])
# ═══════════════════════════════════════════════════════════════
# BUG-05: Instants/sorceries should not remain on the battlefield
# ═══════════════════════════════════════════════════════════════
class TestBug05_SpellResolution:
"""Instants and sorceries are routed to graveyard, not battlefield."""
def test_is_permanent_creature(self):
c = Card(name="Bear", type_line="Creature — Bear")
assert c.is_permanent() is True
def test_is_permanent_artifact(self):
c = Card(name="Sol Ring", type_line="Artifact")
assert c.is_permanent() is True
def test_is_permanent_enchantment(self):
c = Card(name="Rhystic Study", type_line="Enchantment")
assert c.is_permanent() is True
def test_is_not_permanent_instant(self):
c = Card(name="Lightning Bolt", type_line="Instant")
assert c.is_permanent() is False
def test_is_not_permanent_sorcery(self):
c = Card(name="Cultivate", type_line="Sorcery")
assert c.is_permanent() is False
def test_engine_sorcery_not_on_battlefield(self):
"""A non-removal, non-wipe sorcery cast via engine goes to graveyard."""
# Build a deck with a draw sorcery (Harmonize) and lots of lands
harmonize = Card(name="Harmonize")
enrich_card(harmonize)
assert harmonize.type_line == "Sorcery"
# Build mini sim state
sim = SimState(max_turns=25)
p0 = Player(name="Alice", life=40, owner_id=0,
hand=[harmonize],
stats=PlayerStats(cards_drawn=7))
sim.players = [p0, Player(name="Bob", life=40, owner_id=1,
stats=PlayerStats(cards_drawn=7))]
sim.init_battlefields(2)
# Give Alice 5 untapped lands on battlefield
cid = 90000
for i in range(5):
land = Card(name="Forest", type_line="Basic Land — Forest",
cmc=0, id=cid, owner_id=0, tapped=False)
cid += 1
sim.add_to_battlefield(0, land)
sim.next_card_id = cid
engine = GameEngine()
engine._play_spells(sim, 0, 5)
# Harmonize should NOT be on the battlefield
bf = sim.get_battlefield(0)
sorceries = [c for c in bf if c.name == "Harmonize"]
assert len(sorceries) == 0, "Sorcery should not stay on battlefield"
# It should be in the graveyard
assert any(c.name == "Harmonize" for c in p0.graveyard), \
"Sorcery should be in graveyard after resolution"
def test_engine_artifact_stays_on_battlefield(self):
"""An artifact (permanent) should stay on battlefield after being cast."""
sol_ring = Card(name="Sol Ring")
enrich_card(sol_ring)
sim = SimState(max_turns=25)
p0 = Player(name="Alice", life=40, owner_id=0,
hand=[sol_ring],
stats=PlayerStats(cards_drawn=7))
sim.players = [p0, Player(name="Bob", life=40, owner_id=1,
stats=PlayerStats(cards_drawn=7))]
sim.init_battlefields(2)
cid = 90000
for i in range(2):
land = Card(name="Forest", type_line="Basic Land — Forest",
cmc=0, id=cid, owner_id=0, tapped=False)
cid += 1
sim.add_to_battlefield(0, land)
sim.next_card_id = cid
engine = GameEngine()
engine._play_spells(sim, 0, 2)
bf = sim.get_battlefield(0)
artifacts = [c for c in bf if c.name == "Sol Ring"]
assert len(artifacts) == 1, "Artifact should remain on battlefield"
class TestBug05_IsInstantAndSorcery:
"""Card.is_instant() and is_sorcery() helpers."""
def test_instant_detected(self):
c = Card(name="Bolt", type_line="Instant")
assert c.is_instant() is True
assert c.is_sorcery() is False
def test_sorcery_detected(self):
c = Card(name="Wrath", type_line="Sorcery")
assert c.is_sorcery() is True
assert c.is_instant() is False
def test_creature_not_instant_or_sorcery(self):
c = Card(name="Bear", type_line="Creature — Bear")
assert c.is_instant() is False
assert c.is_sorcery() is False
def test_empty_type_line(self):
c = Card(name="?")
assert c.is_instant() is False
assert c.is_sorcery() is False
# ═══════════════════════════════════════════════════════════════
# Integration: full game still runs with all fixes applied
# ═══════════════════════════════════════════════════════════════
class TestIntegration:
"""Sanity check that a full game can still run with all fixes."""
def _make_deck(self, size=60):
"""Build a test deck with mixed card types."""
cards = []
# 24 lands
for _ in range(24):
c = Card(name="Forest")
enrich_card(c)
cards.append(c)
# 15 creatures (with real type data)
for _ in range(15):
c = Card(name="Grizzly Bears", type_line="Creature — Bear",
cmc=2, pt="2/2")
enrich_card(c)
cards.append(c)
# 5 removal instants
for _ in range(5):
c = Card(name="Murder")
enrich_card(c)
cards.append(c)
# 3 board wipes
for _ in range(3):
c = Card(name="Wrath of God")
enrich_card(c)
cards.append(c)
# 5 artifacts (ramp)
for _ in range(5):
c = Card(name="Sol Ring")
enrich_card(c)
cards.append(c)
# 3 ramp sorceries
for _ in range(3):
c = Card(name="Cultivate")
enrich_card(c)
cards.append(c)
# 5 unknown cards (should be "Unknown" type, not creatures)
for i in range(5):
c = Card(name=f"Mystery Card {i}")
enrich_card(c)
cards.append(c)
return cards
def test_full_game_completes(self):
"""A 2-player game with mixed deck should complete without errors."""
engine = GameEngine(max_turns=25)
deck_a = self._make_deck()
deck_b = self._make_deck()
result = engine.run(
[c.clone() for c in deck_a],
[c.clone() for c in deck_b],
name_a="Alice",
name_b="Bob",
)
assert result is not None
assert result.turns > 0
assert result.winner_seat in (-1, 0, 1)
def test_no_artifacts_in_combat_log(self):
"""With record_log, no artifact names should appear in attack events."""
engine = GameEngine(max_turns=10, record_log=True)
deck_a = self._make_deck()
deck_b = self._make_deck()
result = engine.run(
[c.clone() for c in deck_a],
[c.clone() for c in deck_b],
name_a="Alice",
name_b="Bob",
)
for turn_entry in result.game_log:
for phase in turn_entry.get("phases", []):
for event in phase.get("events", []):
if "Attacks" in event:
assert "Sol Ring" not in event, \
f"Sol Ring should not attack: {event}"
def test_unknown_cards_not_in_combat(self):
"""Unknown-type cards should not appear in attack event logs."""
engine = GameEngine(max_turns=10, record_log=True)
deck_a = self._make_deck()
deck_b = self._make_deck()
result = engine.run(
[c.clone() for c in deck_a],
[c.clone() for c in deck_b],
name_a="Alice",
name_b="Bob",
)
for turn_entry in result.game_log:
for phase in turn_entry.get("phases", []):
for event in phase.get("events", []):
if "Attacks" in event:
assert "Mystery Card" not in event, \
f"Unknown card should not attack: {event}"
+8 -1
View File
@@ -67,10 +67,17 @@ class TestEnrichCardDeterministic:
c2 = Card(name="Zzzzz")
enrich_card(c1)
enrich_card(c2)
# At minimum they are both valid creatures with cmc in [2,6]
# At minimum they are both valid Unknown-type cards with cmc in [2,6]
assert 2 <= c1.cmc <= 6
assert 2 <= c2.cmc <= 6
def test_unknown_card_type_is_unknown(self):
"""Unknown cards should get type_line='Unknown', not 'Creature'."""
c = Card(name="Totally Unknown Card XYZ")
enrich_card(c)
assert c.type_line == "Unknown"
assert c.is_creature() is False
def test_unknown_card_cmc_range(self):
for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]:
c = Card(name=name)
+11 -22
View File
@@ -48,20 +48,10 @@ class _GS:
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.priority_seat = 0
self.stack = []
self.game_over = False
self.winner = None
def battlefield(self, seat: int) -> list:
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):
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
@@ -70,7 +60,7 @@ class _GS:
def _run(coro):
return asyncio.run(coro)
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
@@ -82,13 +72,14 @@ class BenchThreatAssessor:
MIN_OPS_PER_SEC = 500
def test_assess_threats_throughput(self):
from commander_ai_lab.sim.threat_assessor import assess_threats
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
start = time.perf_counter()
for _ in range(self.ITERATIONS):
for seat in range(4):
assess_threats(gs, viewer_seat=seat)
ta.assess_threats(viewer_seat=seat)
elapsed = time.perf_counter() - start
ops = self.ITERATIONS * 4
@@ -99,12 +90,13 @@ class BenchThreatAssessor:
)
def test_top_threat_throughput(self):
from commander_ai_lab.sim.threat_assessor import top_threat
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
gs = _GS()
ta = ThreatAssessor(game_state=gs, num_players=4)
start = time.perf_counter()
for _ in range(self.ITERATIONS):
top_threat(gs, viewer_seat=0)
ta.top_threat(viewer_seat=0)
elapsed = time.perf_counter() - start
ops_per_sec = self.ITERATIONS / elapsed
@@ -244,23 +236,20 @@ class BenchIntegrated:
Simulate N full turns: threat assessment + politics check + memory record.
Measures combined per-turn overhead of the AI subsystems.
"""
from commander_ai_lab.sim.threat_assessor import assess_threats
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
from commander_ai_lab.sim.politics.deals import DealType
gs = _GS()
def _threat_dict(viewer_seat: int) -> dict[int, float]:
return {s.seat: s.total for s in assess_threats(gs, viewer_seat=viewer_seat)}
ta = ThreatAssessor(game_state=gs, num_players=4)
mem = TargetingMemory(4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
4, mem, ch,
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
_threat_dict,
lambda s: ta.assess_threats(viewer_seat=s),
)
async def simulate():
@@ -269,7 +258,7 @@ class BenchIntegrated:
active = turn % 4
# Assess threats for all seats
for seat in range(4):
assess_threats(gs, viewer_seat=seat)
ta.assess_threats(viewer_seat=seat)
# Record a simulated attack
attacker = active
defender = (active + 1) % 4
+16 -52
View File
@@ -25,7 +25,7 @@ import pytest
# ---------------------------------------------------------------------------
def run(coro):
return asyncio.run(coro)
return asyncio.get_event_loop().run_until_complete(coro)
@dataclass
@@ -42,38 +42,15 @@ class _Player:
library: list = field(default_factory=list)
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 _GS:
def __init__(self, n=4):
self.players = [_Player(f"P{i}", i) for i in range(n)]
self.turn = 1
self.current_phase = "main1"
self.active_player_seat = 0
self.priority_seat = 0
self.stack = []
self.game_over = False
self.winner = None
self.sim_state = _StubSimState(self.players)
def battlefield(self, seat: int) -> list:
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):
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
@@ -93,17 +70,13 @@ class TestThreatPoliticsIntegration:
"""
def _build(self):
from commander_ai_lab.sim.threat_assessor import assess_threats
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
from commander_ai_lab.sim.politics.memory import TargetingMemory
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
from commander_ai_lab.sim.politics.engine import PoliticsEngine
gs = _GS()
def _threat_dict(viewer_seat: int) -> dict[int, float]:
"""Wrap assess_threats list into {seat: total} dict for PoliticsEngine."""
return {s.seat: s.total for s in assess_threats(gs, viewer_seat=viewer_seat)}
ta = ThreatAssessor(game_state=gs, num_players=4)
mem = TargetingMemory(num_players=4)
ch = PoliticsCommsChannel()
pe = PoliticsEngine(
@@ -111,26 +84,19 @@ class TestThreatPoliticsIntegration:
memory=mem,
comms=ch,
personalities={0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
threat_fn=_threat_dict,
threat_fn=lambda s: ta.assess_threats(viewer_seat=s),
)
return gs, mem, ch, pe
return gs, ta, mem, ch, pe
def test_adjusted_threat_no_spite_equals_base(self):
from commander_ai_lab.sim.threat_assessor import assess_threats
gs, mem, ch, pe = self._build()
scores = assess_threats(gs, viewer_seat=0)
# Find the score for seat 1
base = 0.0
for s in scores:
if s.seat == 1:
base = s.total
break
gs, ta, mem, ch, pe = self._build()
base = ta.assess_threats(viewer_seat=0).get(1, 0.0)
adjusted = pe.adjusted_threat_score(viewer=0, target=1, current_turn=1)
# Without spite or memory events, adjusted ≈ base (may differ by mem term)
assert abs(adjusted - base) < 0.35
def test_spite_raises_adjusted_score(self):
gs, mem, ch, pe = self._build()
gs, ta, mem, ch, pe = self._build()
before = pe.adjusted_threat_score(0, 1, 1)
pe._add_spite(wronged_seat=0, target_seat=1, current_turn=1, intensity=0.5)
after = pe.adjusted_threat_score(0, 1, 1)
@@ -138,7 +104,7 @@ class TestThreatPoliticsIntegration:
def test_memory_aggression_raises_adjusted_score(self):
from commander_ai_lab.sim.politics.memory import ActionType
gs, mem, ch, pe = self._build()
gs, ta, mem, ch, pe = self._build()
before = pe.adjusted_threat_score(0, 2, 1)
for _ in range(5):
mem.record(1, 2, 0, ActionType.ATTACKED)
@@ -146,14 +112,12 @@ class TestThreatPoliticsIntegration:
assert after > before
def test_top_threat_matches_assessor(self):
from commander_ai_lab.sim.threat_assessor import assess_threats
gs, mem, ch, pe = self._build()
gs, ta, mem, ch, pe = self._build()
# Give seat 3 a huge life lead to inflate threat
gs.players[3].life = 5 # low life — looks like they need a kill
threats = assess_threats(gs, viewer_seat=0)
# assess_threats returns a ThreatScore per non-eliminated seat excluding none
# With 4 players, viewer seat 0 is included → 4 scores
assert len(threats) == 4
threats = ta.assess_threats(viewer_seat=0)
# Just confirm assess_threats returns 3 keys for a 4-player game
assert len(threats) == 3
# ---------------------------------------------------------------------------
@@ -315,7 +279,7 @@ class TestTurnManagerIntegration:
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=2),
)
asyncio.run(tm.run_game())
asyncio.get_event_loop().run_until_complete(tm.run_game())
# Game should have fired at least one event
assert len(events) >= 1
@@ -343,7 +307,7 @@ class TestTurnManagerIntegration:
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=1),
)
asyncio.run(tm.run_game())
asyncio.get_event_loop().run_until_complete(tm.run_game())
# If phases were emitted, they should start with untap
if phases_seen:
assert phases_seen[0] in ("untap", "upkeep", "draw", "main1")
@@ -373,7 +337,7 @@ class TestTurnManagerIntegration:
on_thinking=on_thinking,
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=3),
)
asyncio.run(tm.run_game())
asyncio.get_event_loop().run_until_complete(tm.run_game())
assert game_over_fired[0] is True
+4 -65
View File
@@ -39,34 +39,6 @@ class _StubPlayer:
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:
@@ -74,35 +46,11 @@ class _StubGameState:
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."""
@@ -114,14 +62,6 @@ class _StubGameState:
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)
@@ -160,13 +100,12 @@ def _make_tm(
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
# Give seat 0 a big creature
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"),
{"name": "Dragon", "type": "creature", "pt": "6/6", "oracle": ""},
{"name": "Forest", "type": "land", "pt": "", "oracle": ""},
]
gs.players[1].hand = [Card(name=f"Card{i}") for i in range(7)]
gs.players[1].hand = [{"name": f"Card{i}"} for i in range(7)]
return gs
def test_returns_score_per_non_eliminated_player(self):
+3 -12
View File
@@ -15,21 +15,12 @@ import pytest
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_session_store():
"""Reset SessionStore singleton and api module _store before/after each test."""
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
# Also reset the module-level _store in the API router
import commander_ai_lab.web.routers.api as api_mod
api_mod._store = SessionStore()
yield
SessionStore._instance = None
@pytest.fixture
def app():
"""Create a fresh FastAPI app for each test."""
# Reset singleton so tests don't share session state
from commander_ai_lab.web.session_store import SessionStore
SessionStore._instance = None
from commander_ai_lab.web.app import create_app
return create_app()
-291
View File
@@ -1,291 +0,0 @@
"""
Phase 5 Architecture Follow-Up — Unit Tests
============================================
Tests for the three approved decisions:
1. Zone embedding boundary is explicit (docs + code + assertion)
2. SQLite WAL-backed online learning store
3. Forge-only default weights + mixed-mode presets
Run with: pytest tests/test_phase5_followup.py -v
"""
from __future__ import annotations
import json
import os
import sqlite3
import tempfile
import numpy as np
import pytest
# ====================================================================
# Decision 1: Zone Embedding Boundary
# ====================================================================
class TestZoneEmbeddingBoundary:
"""Verify that the zone embedding contract is explicit and enforced."""
def test_state_dims_total_zone_dim(self):
from ml.config.scope import STATE_DIMS
# 4 zones × 2 players × 768 = 6144
assert STATE_DIMS.total_zone_dim == 4 * 2 * 768 == 6144
def test_state_dims_total_state_dim(self):
from ml.config.scope import STATE_DIMS
# 29 global + 6144 zone + 4 playstyle = 6177
assert STATE_DIMS.total_state_dim == 6177
def test_zone_pool_dim_matches_card_embedding_dim(self):
from ml.config.scope import STATE_DIMS
# Zone pool output is same size as card embedding input
assert STATE_DIMS.zone_pool_dim == STATE_DIMS.card_embedding_dim == 768
def test_empty_zone_produces_zero_vector(self):
"""Empty zone must produce a zero vector of exactly zone_pool_dim."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex
idx = CardEmbeddingIndex()
# Even without loading, mean_pool_zone returns a zero vec
vec = idx.mean_pool_zone([])
assert vec.shape == (STATE_DIMS.zone_pool_dim,)
assert np.all(vec == 0.0)
def test_encode_zones_shape_with_empty_players(self):
"""_encode_zones must always return exactly total_zone_dim floats."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
idx = CardEmbeddingIndex()
enc = StateEncoder(idx)
# Two players with empty zones
players = [
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
]
zone_vec = enc._encode_zones(players)
assert zone_vec.shape == (STATE_DIMS.total_zone_dim,)
# All zeros because all zones are empty
assert np.all(zone_vec == 0.0)
def test_full_encode_shape(self):
"""Full state vector must be exactly total_state_dim."""
from ml.config.scope import STATE_DIMS
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
idx = CardEmbeddingIndex()
enc = StateEncoder(idx)
decision = {
"turn": 3,
"phase": "main_1",
"active_seat": 0,
"players": [
{
"seat": 0, "life": 40, "cmdr_dmg": 0, "mana": 4,
"cmdr_tax": 0, "creatures": 2, "lands": 4,
"hand": ["Island", "Counterspell"],
"battlefield": ["Tundra"],
"graveyard": [],
"command_zone": [],
},
{
"seat": 1, "life": 38, "cmdr_dmg": 0, "mana": 3,
"cmdr_tax": 0, "creatures": 1, "lands": 3,
"hand": [],
"battlefield": [],
"graveyard": [],
"command_zone": [],
},
],
}
state = enc.encode(decision, playstyle="control")
assert state.shape == (STATE_DIMS.total_state_dim,)
assert state.dtype == np.float32
def test_boundary_docstring_present(self):
"""StateDimensions must document the zone embedding boundary."""
from ml.config.scope import StateDimensions
assert "zone embedding boundary" in StateDimensions.__doc__.lower()
# ====================================================================
# Decision 2: SQLite WAL Online Learning Store
# ====================================================================
class TestOnlineLearningStore:
"""Verify the SQLite WAL-backed online learning store."""
@pytest.fixture
def store(self, tmp_path):
from ml.serving.online_learning_store import OnlineLearningStore
db_path = str(tmp_path / "test_online.db")
s = OnlineLearningStore(db_path=db_path)
s.init_db()
return s
def test_init_creates_db(self, store):
assert os.path.exists(store.db_path)
def test_wal_mode_enabled(self, store):
conn = store._get_conn()
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert mode == "wal"
def test_record_and_fetch(self, store):
snapshot = {"turn": 1, "phase": "main_1", "players": []}
row_id = store.record_decision(
snapshot=snapshot,
action_idx=3,
confidence=0.85,
playstyle="control",
temperature=0.8,
greedy=False,
)
assert row_id >= 1
decisions = store.fetch_decisions()
assert len(decisions) == 1
d = decisions[0]
assert d["action_idx"] == 3
assert d["confidence"] == pytest.approx(0.85)
assert d["playstyle"] == "control"
assert d["temperature"] == pytest.approx(0.8)
assert d["greedy"] == 0
assert d["snapshot"]["turn"] == 1
def test_count(self, store):
assert store.count() == 0
store.record_decision({"t": 1}, 0, 0.5)
store.record_decision({"t": 2}, 1, 0.6)
assert store.count() == 2
assert store.count(only_unexported=True) == 2
def test_mark_exported(self, store):
store.record_decision({"t": 1}, 0, 0.5)
store.record_decision({"t": 2}, 1, 0.6)
store.record_decision({"t": 3}, 2, 0.7)
# Mark first two as exported
updated = store.mark_exported(up_to_rowid=2)
assert updated == 2
assert store.count(only_unexported=True) == 1
# Fetch only unexported
remaining = store.fetch_decisions(only_unexported=True)
assert len(remaining) == 1
assert remaining[0]["action_idx"] == 2
def test_fetch_since_rowid(self, store):
ids = []
for i in range(5):
ids.append(store.record_decision({"t": i}, i, 0.5))
# Fetch only rows after id 3
results = store.fetch_decisions(since_rowid=ids[2], only_unexported=False)
assert len(results) == 2 # ids[3] and ids[4]
assert results[0]["action_idx"] == 3
def test_fetch_limit(self, store):
for i in range(10):
store.record_decision({"t": i}, i % 8, 0.5)
results = store.fetch_decisions(limit=3)
assert len(results) == 3
def test_close_and_reopen(self, store):
store.record_decision({"t": 1}, 0, 0.5)
store.close()
# Re-open by calling _get_conn again
assert store.count() == 1
def test_snapshot_stored_as_compact_json(self, store):
"""Snapshots should be stored as compact JSON (no extra whitespace)."""
store.record_decision({"key": "value", "num": 42}, 0, 0.5)
conn = store._get_conn()
raw = conn.execute(
"SELECT snapshot FROM online_decisions WHERE id=1"
).fetchone()[0]
# Compact JSON has no spaces after separators
assert " " not in raw
assert json.loads(raw) == {"key": "value", "num": 42}
# ====================================================================
# Decision 3: Forge-Only Defaults + Mixed-Mode Presets
# ====================================================================
class TestForgeOnlyDefaults:
"""Verify default weights are Forge-only (ppo_weight=0.0)."""
def test_distillation_defaults_forge_only(self):
from ml.config.scope import DISTILLATION_DEFAULTS
assert DISTILLATION_DEFAULTS.forge_weight == 1.0
assert DISTILLATION_DEFAULTS.ppo_weight == 0.0
def test_distillation_config_forge_only(self):
from ml.training.distillation_loop import DistillationConfig
cfg = DistillationConfig()
assert cfg.forge_weight == 1.0
assert cfg.ppo_weight == 0.0
def test_dataset_config_forge_only(self):
from ml.data.dataset_builder import DatasetConfig
cfg = DatasetConfig()
assert cfg.source_weights["forge"] == 1.0
assert cfg.source_weights["ppo"] == 0.0
class TestMixedModePresets:
"""Verify mixed-mode weight presets exist and are correct."""
def test_presets_dict_exists(self):
from ml.config.scope import MIXED_MODE_PRESETS
assert isinstance(MIXED_MODE_PRESETS, dict)
assert len(MIXED_MODE_PRESETS) == 3
def test_forge_only_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_only"]
assert p["forge_weight"] == 1.0
assert p["ppo_weight"] == 0.0
def test_forge_90_10_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_90_10"]
assert p["forge_weight"] == 1.0
# ppo_weight ~0.11 gives approximately 10% PPO in the mix
assert 0.1 <= p["ppo_weight"] <= 0.15
def test_forge_80_20_preset(self):
from ml.config.scope import MIXED_MODE_PRESETS
p = MIXED_MODE_PRESETS["forge_80_20"]
assert p["forge_weight"] == 1.0
# ppo_weight ~0.25 gives approximately 20% PPO
assert 0.2 <= p["ppo_weight"] <= 0.3
def test_get_preset_weights_valid(self):
from ml.config.scope import get_preset_weights
w = get_preset_weights("forge_90_10")
assert "forge_weight" in w
assert "ppo_weight" in w
def test_get_preset_weights_returns_copy(self):
"""get_preset_weights must return a copy, not the original dict."""
from ml.config.scope import get_preset_weights, MIXED_MODE_PRESETS
w = get_preset_weights("forge_only")
w["forge_weight"] = 999
assert MIXED_MODE_PRESETS["forge_only"]["forge_weight"] == 1.0
def test_get_preset_weights_invalid(self):
from ml.config.scope import get_preset_weights
with pytest.raises(KeyError, match="Unknown preset"):
get_preset_weights("nonexistent")
def test_all_presets_have_both_keys(self):
from ml.config.scope import MIXED_MODE_PRESETS
for name, preset in MIXED_MODE_PRESETS.items():
assert "forge_weight" in preset, f"Preset '{name}' missing forge_weight"
assert "ppo_weight" in preset, f"Preset '{name}' missing ppo_weight"
+2 -2
View File
@@ -36,7 +36,7 @@ def _engine(personalities=None):
def _run(coro):
return asyncio.run(coro)
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
@@ -217,7 +217,7 @@ class TestPoliticsCommsChannel:
async def h(msg): received_seats.append(s)
return h
import asyncio
h = asyncio.run(make_handler())
h = asyncio.get_event_loop().run_until_complete(make_handler())
channel.register_handler(seat=seat, handler=h)
msg = ThreatBroadcast(speaker=0, audience=-1,
+1 -1
View File
@@ -16,7 +16,7 @@ import pytest
def run(coro):
return asyncio.run(coro)
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------