Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ded23aef |
No files matched your search
+3
-49
@@ -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])
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -343,21 +343,9 @@ class GameEngine:
|
||||
sim.add_to_battlefield(pi, land_card)
|
||||
p.stats.lands_played += 1
|
||||
|
||||
def _send_to_graveyard(self, sim: SimState, card, owner_idx: int,
|
||||
controller_seat: int | None = None,
|
||||
events: list | None = None) -> None:
|
||||
"""Route card to command zone if it's a commander, else graveyard.
|
||||
|
||||
If the dying card has a dies-trigger damage ability, fire it through
|
||||
deal_damage() before the zone change.
|
||||
"""
|
||||
# Fire dies-trigger damage before the card leaves
|
||||
if card.dies_damage and card.is_creature():
|
||||
trigger_seat = controller_seat if controller_seat is not None else owner_idx
|
||||
self._fire_trigger_damage(
|
||||
sim, card, "dies", trigger_seat, events=events,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
|
||||
"""Route card to command zone if it's a commander, else graveyard."""
|
||||
owner = sim.players[owner_idx]
|
||||
if card.is_commander:
|
||||
card.tapped = False
|
||||
@@ -366,111 +354,6 @@ class GameEngine:
|
||||
else:
|
||||
owner.graveyard.append(card)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized Damage
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def deal_damage(
|
||||
sim: SimState,
|
||||
amount: int,
|
||||
target_seat: int,
|
||||
source_card: Card | None = None,
|
||||
source_seat: int | None = None,
|
||||
is_combat: bool = False,
|
||||
events: list | None = None,
|
||||
label: str = "",
|
||||
) -> int:
|
||||
"""Apply *amount* damage to a player, updating stats and checking elimination.
|
||||
|
||||
Returns the actual damage dealt (always *amount* in this simplified model;
|
||||
future prevention/replacement effects will reduce it here).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sim : SimState
|
||||
amount : int – damage to deal (<=0 is a no-op)
|
||||
target_seat : int – seat index of the player receiving damage
|
||||
source_card : Card | None – the card that is the source (for logging)
|
||||
source_seat : int | None – seat index of the damage source's controller
|
||||
is_combat : bool – whether this is combat damage (for future commander tracking)
|
||||
events : list | None – optional event log
|
||||
label : str – descriptive label for the log entry
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0
|
||||
target = sim.players[target_seat]
|
||||
if target.eliminated:
|
||||
return 0
|
||||
|
||||
target.life -= amount
|
||||
target.stats.damage_received += amount
|
||||
if source_seat is not None:
|
||||
sim.players[source_seat].stats.damage_dealt += amount
|
||||
|
||||
if events is not None and label:
|
||||
events.append(f"{label} ({target.name} now at {target.life} life)")
|
||||
|
||||
if target.life <= 0:
|
||||
target.eliminated = True
|
||||
return amount
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Triggered-Ability Damage
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def _fire_trigger_damage(
|
||||
self,
|
||||
sim: SimState,
|
||||
card: Card,
|
||||
trigger_kind: str,
|
||||
controller_seat: int,
|
||||
events: list | None = None,
|
||||
) -> None:
|
||||
"""Resolve a simple ETB or dies damage trigger through deal_damage().
|
||||
|
||||
trigger_kind: "etb" | "dies"
|
||||
"""
|
||||
if trigger_kind == "etb":
|
||||
amount = card.etb_damage
|
||||
target_mode = card.etb_damage_target
|
||||
elif trigger_kind == "dies":
|
||||
amount = card.dies_damage
|
||||
target_mode = card.dies_damage_target
|
||||
else:
|
||||
return
|
||||
|
||||
if amount <= 0:
|
||||
return
|
||||
|
||||
if target_mode == "each_opponent":
|
||||
for si, sp in enumerate(sim.players):
|
||||
if si == controller_seat or sp.eliminated:
|
||||
continue
|
||||
label = f"{card.name} {trigger_kind} trigger deals {amount} to {sp.name}"
|
||||
self.deal_damage(
|
||||
sim, amount, si,
|
||||
source_card=card, source_seat=controller_seat,
|
||||
events=events, label=label,
|
||||
)
|
||||
else:
|
||||
# "opponent" / "any_target" — target the weakest non-eliminated opponent
|
||||
best = self._select_attack_target(sim, controller_seat)
|
||||
if best != -1:
|
||||
target_name = sim.players[best].name
|
||||
label = f"{card.name} {trigger_kind} trigger deals {amount} to {target_name}"
|
||||
self.deal_damage(
|
||||
sim, amount, best,
|
||||
source_card=card, source_seat=controller_seat,
|
||||
events=events, label=label,
|
||||
)
|
||||
|
||||
def _fire_etb_trigger(self, sim: SimState, card: Card, controller_seat: int,
|
||||
events: list | None = None) -> None:
|
||||
"""Fire an ETB damage trigger if the card has one."""
|
||||
if card.etb_damage and card.is_creature():
|
||||
self._fire_trigger_damage(sim, card, "etb", controller_seat, events=events)
|
||||
|
||||
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
|
||||
"""Count untapped lands for a player."""
|
||||
return sum(
|
||||
@@ -531,8 +414,7 @@ class GameEngine:
|
||||
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
|
||||
killed = all_opp_creatures[0]
|
||||
sim.remove_from_battlefield(killed.id)
|
||||
self._send_to_graveyard(sim, killed, killed.owner_id,
|
||||
controller_seat=killed.owner_id, events=events)
|
||||
self._send_to_graveyard(sim, killed, killed.owner_id)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal) \u2014 destroyed {killed.name}")
|
||||
else:
|
||||
@@ -548,8 +430,7 @@ class GameEngine:
|
||||
for c in bf:
|
||||
if c.is_creature():
|
||||
wiped_names.append(c.name)
|
||||
self._send_to_graveyard(sim, c, c.owner_id,
|
||||
controller_seat=c.owner_id, events=events)
|
||||
self._send_to_graveyard(sim, c, c.owner_id)
|
||||
else:
|
||||
keep.append(c)
|
||||
sim.battlefields[seat_idx] = keep
|
||||
@@ -557,13 +438,17 @@ 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:
|
||||
events.append(f"Cast {card.name} ({card.pt or 'creature'}) for {card.cmc} mana")
|
||||
# Fire ETB damage trigger
|
||||
self._fire_etb_trigger(sim, card, pi, events=events)
|
||||
elif card.is_ramp:
|
||||
p.stats.ramp_played += 1
|
||||
if events is not None:
|
||||
@@ -602,9 +487,6 @@ class GameEngine:
|
||||
f"Cast commander {cmd_card.name} from command zone "
|
||||
f"for {total_cost} mana (tax={tax})"
|
||||
)
|
||||
# Fire ETB damage trigger for commander creatures
|
||||
if cmd_card.is_creature():
|
||||
self._fire_etb_trigger(sim, cmd_card, pi, events=events)
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
|
||||
@staticmethod
|
||||
@@ -639,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"))
|
||||
)
|
||||
@@ -650,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
|
||||
)
|
||||
]
|
||||
@@ -719,17 +601,15 @@ class GameEngine:
|
||||
blocker.damage_marked += a_pow
|
||||
if blocker.damage_marked >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
sim.remove_from_battlefield(blocker.id)
|
||||
self._send_to_graveyard(sim, blocker, opp_idx)
|
||||
if events is not None:
|
||||
combat_details.append(f" {blocker.name} dies")
|
||||
self._send_to_graveyard(sim, blocker, opp_idx,
|
||||
controller_seat=opp_idx, events=events)
|
||||
atk.damage_marked += b_pow
|
||||
if atk.damage_marked >= a_tou or blocker.has_keyword("deathtouch"):
|
||||
sim.remove_from_battlefield(atk.id)
|
||||
self._send_to_graveyard(sim, atk, pi)
|
||||
if events is not None:
|
||||
combat_details.append(f" {atk.name} dies")
|
||||
self._send_to_graveyard(sim, atk, pi,
|
||||
controller_seat=pi, events=events)
|
||||
trample_over = 0
|
||||
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
|
||||
trample_over = a_pow - blocker.get_toughness()
|
||||
@@ -753,15 +633,14 @@ class GameEngine:
|
||||
total_damage += a_pow
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow
|
||||
if total_damage > 0:
|
||||
self.deal_damage(
|
||||
sim, total_damage, opp_idx,
|
||||
source_seat=pi, is_combat=True,
|
||||
events=events,
|
||||
label=f"Dealt {total_damage} combat damage to {opp.name}",
|
||||
)
|
||||
if events is not None:
|
||||
events.extend(combat_details)
|
||||
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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -38,12 +38,6 @@ class Card:
|
||||
is_board_wipe: bool = False
|
||||
is_commander: bool = False
|
||||
|
||||
# Triggered-ability damage flags (set by enrich_card)
|
||||
etb_damage: int = 0 # damage dealt when this creature enters the battlefield
|
||||
dies_damage: int = 0 # damage dealt when this creature dies
|
||||
etb_damage_target: str = "" # "opponent" | "any_target" | "each_opponent"
|
||||
dies_damage_target: str = "" # "opponent" | "any_target" | "each_opponent"
|
||||
|
||||
# Runtime fields (set during game)
|
||||
id: int = 0
|
||||
owner_id: int = -1
|
||||
@@ -96,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -186,7 +188,7 @@ def enrich_card(card: Card) -> Card:
|
||||
|
||||
|
||||
def _apply_oracle_flags(card: Card) -> None:
|
||||
"""Set is_removal, is_ramp, is_board_wipe, and trigger-damage flags from oracle_text and type_line."""
|
||||
"""Set is_removal, is_ramp, is_board_wipe from oracle_text and type_line."""
|
||||
oracle = (card.oracle_text or "").lower()
|
||||
type_line = (card.type_line or "").lower()
|
||||
|
||||
@@ -223,60 +225,6 @@ def _apply_oracle_flags(card: Card) -> None:
|
||||
):
|
||||
card.is_ramp = True
|
||||
|
||||
# Triggered-ability damage detection (ETB / dies)
|
||||
_detect_trigger_damage(card, oracle)
|
||||
|
||||
|
||||
# ── Trigger-damage detection ─────────────────────────────────
|
||||
|
||||
# Patterns: "when ~ enters the battlefield, ~ deals N damage to ..."
|
||||
# and "when ~ dies, ~ deals N damage to ..."
|
||||
_ETB_DAMAGE_RE = re.compile(
|
||||
r"when\b.*\benters\b.*\bdeals?\s+(\d+)\s+damage\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIES_DAMAGE_RE = re.compile(
|
||||
r"when\b.*\bdies\b.*\bdeals?\s+(\d+)\s+damage\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _detect_trigger_damage(card: Card, oracle: str) -> None:
|
||||
"""Detect ETB and dies damage triggers from oracle text and set card flags."""
|
||||
if not oracle:
|
||||
return
|
||||
|
||||
# ETB damage
|
||||
if not card.etb_damage:
|
||||
m = _ETB_DAMAGE_RE.search(oracle)
|
||||
if m:
|
||||
card.etb_damage = int(m.group(1))
|
||||
card.etb_damage_target = _classify_trigger_target(oracle, "enters")
|
||||
|
||||
# Dies damage
|
||||
if not card.dies_damage:
|
||||
m = _DIES_DAMAGE_RE.search(oracle)
|
||||
if m:
|
||||
card.dies_damage = int(m.group(1))
|
||||
card.dies_damage_target = _classify_trigger_target(oracle, "dies")
|
||||
|
||||
|
||||
def _classify_trigger_target(oracle: str, trigger_word: str) -> str:
|
||||
"""Classify the target mode of a damage trigger from oracle text.
|
||||
|
||||
Returns "each_opponent", "any_target", or "opponent".
|
||||
"""
|
||||
# Find the clause containing the trigger word
|
||||
# Look for "each opponent" or "each player" after the trigger
|
||||
idx = oracle.find(trigger_word)
|
||||
clause = oracle[idx:] if idx != -1 else oracle
|
||||
|
||||
if "each opponent" in clause or "each player" in clause:
|
||||
return "each_opponent"
|
||||
if "any target" in clause or "target creature or player" in clause or "target creature or planeswalker" in clause:
|
||||
return "any_target"
|
||||
return "opponent"
|
||||
|
||||
|
||||
# ── Multi-opponent scaling factors ────────────────────────────
|
||||
# Keywords / effects that become more valuable in larger pods.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
+16
-11
@@ -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],
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
"""Regression tests for triggered-ability damage routing.
|
||||
|
||||
Tests that ETB and dies damage triggers fire through the centralized
|
||||
deal_damage() path, correctly update life/stats, and handle edge cases
|
||||
like each-opponent targeting, board wipes, removal kills, and combat deaths.
|
||||
"""
|
||||
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_creature(name="Bear", pt="2/2", cmc=2, oracle_text="", **kwargs) -> Card:
|
||||
"""Build a creature Card with optional oracle text for trigger detection."""
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
cmc=cmc,
|
||||
pt=pt,
|
||||
power=pt.split("/")[0],
|
||||
toughness=pt.split("/")[1],
|
||||
oracle_text=oracle_text,
|
||||
**kwargs,
|
||||
)
|
||||
enrich_card(c)
|
||||
return c
|
||||
|
||||
|
||||
def _make_land(name="Forest") -> Card:
|
||||
c = Card(name=name)
|
||||
enrich_card(c)
|
||||
return c
|
||||
|
||||
|
||||
def _setup_two_player(
|
||||
p0_hand=None, p1_hand=None, p0_life=40, p1_life=40,
|
||||
p0_battlefield=None, p1_battlefield=None,
|
||||
) -> tuple[GameEngine, SimState]:
|
||||
"""Set up a 2-player SimState with controlled hands/battlefields."""
|
||||
engine = GameEngine(max_turns=5)
|
||||
sim = SimState(max_turns=5)
|
||||
for i, (hand, life, bf) in enumerate([
|
||||
(p0_hand or [], p0_life, p0_battlefield or []),
|
||||
(p1_hand or [], p1_life, p1_battlefield or []),
|
||||
]):
|
||||
p = Player(
|
||||
name=f"Player {i}",
|
||||
life=life,
|
||||
owner_id=i,
|
||||
hand=list(hand),
|
||||
library=[_make_land() for _ in range(30)],
|
||||
stats=PlayerStats(cards_drawn=7),
|
||||
)
|
||||
sim.players.append(p)
|
||||
sim.init_battlefields(2)
|
||||
# Place battlefield cards
|
||||
for seat, bf_cards in enumerate([p0_battlefield or [], p1_battlefield or []]):
|
||||
for c in bf_cards:
|
||||
c.owner_id = seat
|
||||
c.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
c.turn_played = -1 # not summoning sick
|
||||
sim.add_to_battlefield(seat, c)
|
||||
return engine, sim
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# deal_damage() centralized method tests
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDealDamage:
|
||||
def test_basic_damage_reduces_life(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert dealt == 5
|
||||
assert sim.players[1].life == 15
|
||||
|
||||
def test_damage_updates_stats(self):
|
||||
engine, sim = _setup_two_player()
|
||||
engine.deal_damage(sim, 3, target_seat=1, source_seat=0)
|
||||
assert sim.players[0].stats.damage_dealt == 3
|
||||
assert sim.players[1].stats.damage_received == 3
|
||||
|
||||
def test_lethal_damage_eliminates(self):
|
||||
engine, sim = _setup_two_player(p1_life=3)
|
||||
engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].life == -2
|
||||
|
||||
def test_zero_damage_noop(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
dealt = engine.deal_damage(sim, 0, target_seat=1, source_seat=0)
|
||||
assert dealt == 0
|
||||
assert sim.players[1].life == 20
|
||||
|
||||
def test_damage_to_eliminated_player_noop(self):
|
||||
engine, sim = _setup_two_player(p1_life=0)
|
||||
sim.players[1].eliminated = True
|
||||
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert dealt == 0
|
||||
|
||||
def test_damage_logs_events(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
events = []
|
||||
engine.deal_damage(sim, 4, target_seat=1, source_seat=0,
|
||||
events=events, label="Test hit deals 4")
|
||||
assert len(events) == 1
|
||||
assert "Test hit deals 4" in events[0]
|
||||
assert "16 life" in events[0]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Trigger detection (enrich_card) tests
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTriggerDetection:
|
||||
def test_etb_damage_detected(self):
|
||||
"""Cards like 'Flametongue Kavu' style ETB damage should be detected."""
|
||||
c = _make_creature(
|
||||
name="Flametongue Kavu", pt="4/2", cmc=4,
|
||||
oracle_text="When Flametongue Kavu enters the battlefield, it deals 4 damage to target creature.",
|
||||
)
|
||||
assert c.etb_damage == 4
|
||||
assert c.etb_damage_target in ("opponent", "any_target")
|
||||
|
||||
def test_dies_damage_detected(self):
|
||||
"""Cards with dies-trigger damage should be detected."""
|
||||
c = _make_creature(
|
||||
name="Vindictive Lich", pt="4/1", cmc=4,
|
||||
oracle_text="When Vindictive Lich dies, it deals 3 damage to target opponent.",
|
||||
)
|
||||
assert c.dies_damage == 3
|
||||
assert c.dies_damage_target == "opponent"
|
||||
|
||||
def test_each_opponent_etb(self):
|
||||
"""'each opponent' ETB damage correctly classified."""
|
||||
c = _make_creature(
|
||||
name="Siege-Gang Commander", pt="2/2", cmc=5,
|
||||
oracle_text="When Siege-Gang Commander enters the battlefield, it deals 2 damage to each opponent.",
|
||||
)
|
||||
assert c.etb_damage == 2
|
||||
assert c.etb_damage_target == "each_opponent"
|
||||
|
||||
def test_each_opponent_dies(self):
|
||||
"""'each opponent' dies damage correctly classified."""
|
||||
c = _make_creature(
|
||||
name="Fiery Emancipation", pt="3/3", cmc=4,
|
||||
oracle_text="When Fiery Emancipation dies, it deals 3 damage to each opponent.",
|
||||
)
|
||||
assert c.dies_damage == 3
|
||||
assert c.dies_damage_target == "each_opponent"
|
||||
|
||||
def test_no_trigger_on_vanilla(self):
|
||||
"""Vanilla creatures should have no trigger damage."""
|
||||
c = _make_creature(name="Grizzly Bears", pt="2/2", cmc=2)
|
||||
assert c.etb_damage == 0
|
||||
assert c.dies_damage == 0
|
||||
|
||||
def test_any_target_detected(self):
|
||||
"""'any target' ETB should be detected."""
|
||||
c = _make_creature(
|
||||
name="Burning Sun's Avatar", pt="6/6", cmc=6,
|
||||
oracle_text="When Burning Sun's Avatar enters the battlefield, it deals 3 damage to any target.",
|
||||
)
|
||||
assert c.etb_damage == 3
|
||||
assert c.etb_damage_target == "any_target"
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# ETB trigger firing tests
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestETBTriggerFiring:
|
||||
def test_etb_deals_damage_to_opponent(self):
|
||||
"""ETB creature with 'opponent' target damages weakest opponent."""
|
||||
etb_creature = _make_creature(
|
||||
name="Inferno Titan", pt="6/6", cmc=6,
|
||||
oracle_text="When Inferno Titan enters the battlefield, it deals 3 damage to target opponent.",
|
||||
)
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[etb_creature],
|
||||
p0_battlefield=[_make_land() for _ in range(6)],
|
||||
p1_life=20,
|
||||
)
|
||||
events = []
|
||||
engine._fire_etb_trigger(sim, etb_creature, controller_seat=0, events=events)
|
||||
assert sim.players[1].life == 17
|
||||
assert sim.players[0].stats.damage_dealt == 3
|
||||
assert any("etb trigger" in e for e in events)
|
||||
|
||||
def test_etb_each_opponent_hits_all(self):
|
||||
"""'each_opponent' ETB in a 3-player game damages all opponents."""
|
||||
etb_creature = _make_creature(
|
||||
name="Purphoros", pt="5/5", cmc=4,
|
||||
oracle_text="When Purphoros enters the battlefield, it deals 2 damage to each opponent.",
|
||||
)
|
||||
engine = GameEngine(max_turns=5)
|
||||
sim = SimState(max_turns=5)
|
||||
for i in range(3):
|
||||
sim.players.append(Player(
|
||||
name=f"P{i}", life=20, owner_id=i,
|
||||
library=[_make_land() for _ in range(10)],
|
||||
stats=PlayerStats(cards_drawn=7),
|
||||
))
|
||||
sim.init_battlefields(3)
|
||||
events = []
|
||||
engine._fire_etb_trigger(sim, etb_creature, controller_seat=0, events=events)
|
||||
assert sim.players[0].life == 20 # controller unaffected
|
||||
assert sim.players[1].life == 18
|
||||
assert sim.players[2].life == 18
|
||||
assert sim.players[0].stats.damage_dealt == 4 # 2 to each of 2 opponents
|
||||
|
||||
def test_etb_no_trigger_on_vanilla(self):
|
||||
"""Vanilla creature ETB should not deal damage."""
|
||||
bear = _make_creature(name="Bear", pt="2/2", cmc=2)
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
engine._fire_etb_trigger(sim, bear, controller_seat=0)
|
||||
assert sim.players[1].life == 20
|
||||
|
||||
def test_etb_fires_during_spell_cast(self):
|
||||
"""ETB trigger fires when creature is played via _play_spells."""
|
||||
etb_creature = _make_creature(
|
||||
name="Avalanche Riders", pt="2/2", cmc=4,
|
||||
oracle_text="When Avalanche Riders enters the battlefield, it deals 2 damage to target opponent.",
|
||||
)
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[etb_creature],
|
||||
p0_battlefield=[_make_land() for _ in range(4)],
|
||||
p1_life=20,
|
||||
)
|
||||
events = []
|
||||
available_mana = 4
|
||||
engine._play_spells(sim, 0, available_mana, events)
|
||||
# Creature should have been cast and ETB should have fired
|
||||
assert sim.players[1].life == 18
|
||||
assert any("etb trigger" in e.lower() for e in events)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Dies trigger firing tests
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDiesTriggerFiring:
|
||||
def test_dies_trigger_on_removal(self):
|
||||
"""Dies trigger fires when creature is killed by removal."""
|
||||
dies_creature = _make_creature(
|
||||
name="Vengeful Dead", pt="3/2", cmc=3,
|
||||
oracle_text="When Vengeful Dead dies, it deals 2 damage to target opponent.",
|
||||
)
|
||||
removal = Card(name="Murder", type_line="Instant", cmc=3,
|
||||
oracle_text="Destroy target creature.", is_removal=True)
|
||||
enrich_card(removal)
|
||||
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[removal],
|
||||
p0_battlefield=[_make_land() for _ in range(3)],
|
||||
p1_battlefield=[dies_creature],
|
||||
p0_life=20,
|
||||
)
|
||||
events = []
|
||||
engine._play_spells(sim, 0, 3, events)
|
||||
# Murder kills the dies_creature; its dies trigger should deal 2 to P0
|
||||
# (since P1 controls the dying creature, P1's trigger targets the weakest opponent = P0)
|
||||
assert sim.players[0].life == 18 # dies trigger dealt 2 to P0
|
||||
|
||||
def test_dies_trigger_on_board_wipe(self):
|
||||
"""Dies triggers fire for each creature destroyed by board wipe."""
|
||||
dies_a = _make_creature(
|
||||
name="Doomed Necro", pt="2/2", cmc=2,
|
||||
oracle_text="When Doomed Necro dies, it deals 1 damage to each opponent.",
|
||||
)
|
||||
dies_b = _make_creature(
|
||||
name="Spite Elemental", pt="3/1", cmc=3,
|
||||
oracle_text="When Spite Elemental dies, it deals 1 damage to each opponent.",
|
||||
)
|
||||
wipe = Card(name="Wrath of God", type_line="Sorcery", cmc=4,
|
||||
oracle_text="Destroy all creatures.", is_board_wipe=True)
|
||||
enrich_card(wipe)
|
||||
|
||||
p0_bf = [_make_land() for _ in range(4)] + [dies_a]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[wipe],
|
||||
p0_battlefield=p0_bf,
|
||||
p1_battlefield=[dies_b],
|
||||
p0_life=20,
|
||||
p1_life=20,
|
||||
)
|
||||
events = []
|
||||
engine._play_spells(sim, 0, 4, events)
|
||||
# dies_a (owned by P0) dies → deals 1 to each opponent → P1 takes 1
|
||||
# dies_b (owned by P1) dies → deals 1 to each opponent → P0 takes 1
|
||||
assert sim.players[0].life == 19 # took 1 from dies_b
|
||||
assert sim.players[1].life == 19 # took 1 from dies_a
|
||||
|
||||
def test_dies_trigger_in_combat_lethal_block(self):
|
||||
"""Dies trigger fires when creature is killed by a lethal blocker.
|
||||
|
||||
The 3/1 has power >= 3 so the attack heuristic will choose to attack.
|
||||
The 2/2 blocker has toughness > power-of-attacker=3? No, 2 < 3. But
|
||||
the blocker will still block to save life. The blocker deals 2 damage
|
||||
to the 3/1 (toughness 1) → attacker dies. Its dies trigger fires.
|
||||
"""
|
||||
dies_creature = _make_creature(
|
||||
name="Goblin Arsonist", pt="3/1", cmc=1,
|
||||
oracle_text="When Goblin Arsonist dies, it deals 1 damage to target opponent.",
|
||||
)
|
||||
blocker = _make_creature(name="Grizzly Bears", pt="2/2", cmc=2)
|
||||
|
||||
engine, sim = _setup_two_player(
|
||||
p0_battlefield=[dies_creature],
|
||||
p1_battlefield=[blocker],
|
||||
p0_life=20,
|
||||
p1_life=20,
|
||||
)
|
||||
dies_creature.turn_played = -1
|
||||
|
||||
events = []
|
||||
engine._resolve_combat(sim, 0, turn=1, events=events)
|
||||
# 3/1 attacks (power >= 3), 2/2 blocks. Bear deals 2 damage to 3/1 (toughness 1) → dies.
|
||||
# 3/1 deals 3 damage to bear (toughness 2) → bear also dies.
|
||||
# Dies trigger on the 3/1: deals 1 damage to P1 (weakest opponent).
|
||||
# Combat damage: 0 (both blocked and killed each other, no trample)
|
||||
# The bear's death also triggers _send_to_graveyard but bear has no dies_damage.
|
||||
assert sim.players[1].life <= 19 # got hit by dies trigger (1 damage)
|
||||
|
||||
|
||||
class TestDiesTriggerEachOpponent:
|
||||
def test_dies_each_opponent_3player(self):
|
||||
"""'each_opponent' dies trigger hits all opponents in 3-player game."""
|
||||
dies_creature = _make_creature(
|
||||
name="Blood Artist", pt="0/1", cmc=2,
|
||||
oracle_text="When Blood Artist dies, it deals 1 damage to each opponent.",
|
||||
)
|
||||
engine = GameEngine(max_turns=5)
|
||||
sim = SimState(max_turns=5)
|
||||
for i in range(3):
|
||||
sim.players.append(Player(
|
||||
name=f"P{i}", life=20, owner_id=i,
|
||||
library=[_make_land() for _ in range(10)],
|
||||
stats=PlayerStats(cards_drawn=7),
|
||||
))
|
||||
sim.init_battlefields(3)
|
||||
dies_creature.owner_id = 0
|
||||
dies_creature.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
sim.add_to_battlefield(0, dies_creature)
|
||||
|
||||
events = []
|
||||
# Simulate the creature dying
|
||||
sim.remove_from_battlefield(dies_creature.id)
|
||||
engine._send_to_graveyard(sim, dies_creature, 0, controller_seat=0, events=events)
|
||||
|
||||
assert sim.players[0].life == 20 # controller unaffected
|
||||
assert sim.players[1].life == 19
|
||||
assert sim.players[2].life == 19
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Combat damage through deal_damage() tests
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCombatDamageCentralized:
|
||||
def test_combat_damage_routes_through_deal_damage(self):
|
||||
"""Combat damage should update stats via deal_damage() path."""
|
||||
attacker = _make_creature(name="Goblin", pt="3/1", cmc=1)
|
||||
engine, sim = _setup_two_player(
|
||||
p0_battlefield=[attacker],
|
||||
p1_life=20,
|
||||
)
|
||||
attacker.turn_played = -1 # not summoning sick
|
||||
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
# 3/1 attacks unblocked → 3 damage to P1
|
||||
assert sim.players[1].life == 17
|
||||
assert sim.players[0].stats.damage_dealt == 3
|
||||
assert sim.players[1].stats.damage_received == 3
|
||||
|
||||
def test_combat_lethal_eliminates_via_deal_damage(self):
|
||||
"""Lethal combat damage eliminates the opponent via deal_damage()."""
|
||||
big = _make_creature(name="Big Boy", pt="10/10", cmc=5)
|
||||
engine, sim = _setup_two_player(
|
||||
p0_battlefield=[big],
|
||||
p1_life=5,
|
||||
)
|
||||
big.turn_played = -1
|
||||
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].life <= 0
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Integration: full game with trigger creatures
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTriggeredDamageIntegration:
|
||||
def test_full_game_with_etb_creatures_completes(self):
|
||||
"""A full game with ETB damage creatures should complete without error."""
|
||||
def _deck_with_etb(seed_offset=0):
|
||||
cards = []
|
||||
for i in range(24):
|
||||
cards.append(_make_land())
|
||||
for i in range(10):
|
||||
cards.append(_make_creature(
|
||||
name=f"ETB Creature {i + seed_offset}",
|
||||
pt="2/2", cmc=3,
|
||||
oracle_text="When this creature enters the battlefield, it deals 1 damage to target opponent.",
|
||||
))
|
||||
for i in range(10):
|
||||
cards.append(_make_creature(
|
||||
name=f"Dies Creature {i + seed_offset}",
|
||||
pt="1/1", cmc=2,
|
||||
oracle_text="When this creature dies, it deals 1 damage to each opponent.",
|
||||
))
|
||||
for i in range(16):
|
||||
cards.append(_make_creature(
|
||||
name=f"Vanilla {i + seed_offset}", pt="3/3", cmc=3,
|
||||
))
|
||||
return cards
|
||||
|
||||
engine = GameEngine(max_turns=15)
|
||||
result = engine.run(
|
||||
deck_a=_deck_with_etb(0),
|
||||
deck_b=_deck_with_etb(100),
|
||||
name_a="ETB Player",
|
||||
name_b="Dies Player",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_etb_can_cause_elimination(self):
|
||||
"""ETB trigger dealing lethal damage should eliminate the opponent."""
|
||||
lethal_etb = _make_creature(
|
||||
name="Lethal ETB", pt="1/1", cmc=1,
|
||||
oracle_text="When Lethal ETB enters the battlefield, it deals 5 damage to target opponent.",
|
||||
)
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[lethal_etb],
|
||||
p0_battlefield=[_make_land()],
|
||||
p1_life=3,
|
||||
)
|
||||
events = []
|
||||
engine._play_spells(sim, 0, 1, events)
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].life == -2
|
||||
+11
-22
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@ import pytest
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user