Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5955473d7 | ||
|
|
cf4e2981e6 | ||
|
|
d795e1a8fd | ||
|
|
01d49e78a5 |
No files matched your search
+49
-3
@@ -155,15 +155,25 @@ 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
|
||||
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
|
||||
|
||||
# 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
|
||||
@@ -268,6 +278,10 @@ 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
|
||||
@@ -281,9 +295,9 @@ class DistillationDefaults:
|
||||
ppo_entropy_coeff: float = 0.01
|
||||
ppo_eval_episodes: int = 100
|
||||
|
||||
# Dataset mixing
|
||||
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
|
||||
forge_weight: float = 1.0
|
||||
ppo_weight: float = 0.5
|
||||
ppo_weight: float = 0.0
|
||||
min_reward_threshold: float = 0.0
|
||||
|
||||
# Quality gate thresholds
|
||||
@@ -302,3 +316,35 @@ 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.
|
||||
# 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.
|
||||
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
|
||||
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
|
||||
source_weights: Dict[str, float] = field(
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.5}
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
|
||||
)
|
||||
|
||||
# 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.5}
|
||||
source_weights = {"forge": 1.0, "ppo": 0.0}
|
||||
|
||||
# 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.5,
|
||||
help="Sampling weight for PPO self-play data (default: 0.5)",
|
||||
"--ppo-weight", type=float, default=0.0,
|
||||
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-reward", type=float, default=0.0,
|
||||
|
||||
@@ -276,6 +276,13 @@ 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 = []
|
||||
@@ -283,6 +290,8 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
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 ---
|
||||
# --- Dataset building (Forge-only default; see MIXED_MODE_PRESETS) ---
|
||||
forge_weight: float = 1.0
|
||||
ppo_weight: float = 0.5
|
||||
ppo_weight: float = 0.0
|
||||
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.5)
|
||||
parser.add_argument("--ppo-weight", type=float, default=0.0)
|
||||
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 image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
"SELECT scryfall_id, 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": ce["image_url"] if ce else None,
|
||||
"image_url": f"https://api.scryfall.com/cards/{ce['scryfall_id']}?format=image&version=normal" if ce and ce["scryfall_id"] else None,
|
||||
"tcg_price": ce["tcg_price"] if ce else None,
|
||||
})
|
||||
|
||||
|
||||
+126
-2
@@ -188,10 +188,95 @@ 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."""
|
||||
@@ -777,6 +862,8 @@ 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
|
||||
@@ -800,6 +887,8 @@ 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"),
|
||||
@@ -847,9 +936,21 @@ 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."""
|
||||
"""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``.
|
||||
"""
|
||||
if _distillation_state.running:
|
||||
raise HTTPException(409, "Distillation loop already in progress")
|
||||
if _training_state.running:
|
||||
@@ -871,6 +972,22 @@ 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
|
||||
@@ -890,12 +1007,19 @@ 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}
|
||||
return {
|
||||
"status": "started",
|
||||
"max_iterations": max_iterations,
|
||||
"forge_weight": forge_weight,
|
||||
"ppo_weight": ppo_weight,
|
||||
"preset": preset_name,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ml/distill/status")
|
||||
|
||||
@@ -63,6 +63,40 @@ 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
|
||||
@@ -409,40 +443,9 @@ 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 = self._CANNED.get(self.personality.key, {})
|
||||
canned = _CANNED_NARRATIONS.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,6 +343,53 @@ class GameEngine:
|
||||
sim.add_to_battlefield(pi, land_card)
|
||||
p.stats.lands_played += 1
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized Damage
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _apply_damage(
|
||||
sim: SimState,
|
||||
amount: int,
|
||||
target_seat: int,
|
||||
source_seat: int,
|
||||
*,
|
||||
source_is_commander: bool = False,
|
||||
is_combat: bool = False,
|
||||
) -> int:
|
||||
"""Apply damage to a player through a single centralized path.
|
||||
|
||||
Handles:
|
||||
- Life total reduction
|
||||
- Commander damage tracking (when source_is_commander=True)
|
||||
- Per-player damage stats
|
||||
- Elimination by life<=0 or commander damage>=21
|
||||
|
||||
Returns the amount of damage actually applied (always ``amount``
|
||||
unless the target is already eliminated, in which case 0).
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0
|
||||
target = sim.players[target_seat]
|
||||
if target.eliminated:
|
||||
return 0
|
||||
|
||||
target.life -= amount
|
||||
target.stats.damage_received += amount
|
||||
sim.players[source_seat].stats.damage_dealt += amount
|
||||
|
||||
if source_is_commander:
|
||||
prev = target.commander_damage_received.get(source_seat, 0)
|
||||
target.commander_damage_received[source_seat] = prev + amount
|
||||
|
||||
# Elimination checks: life<=0 OR 21+ commander damage from one source
|
||||
if target.life <= 0:
|
||||
target.eliminated = True
|
||||
elif any(v >= 21 for v in target.commander_damage_received.values()):
|
||||
target.eliminated = True
|
||||
|
||||
return amount
|
||||
|
||||
@staticmethod
|
||||
def _send_to_graveyard(sim: SimState, card, owner_idx: int) -> None:
|
||||
"""Route card to command zone if it's a commander, else graveyard."""
|
||||
@@ -556,9 +603,13 @@ class GameEngine:
|
||||
if events is not None and attackers:
|
||||
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
|
||||
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
|
||||
total_damage = 0
|
||||
# Accumulate per-attacker player-damage buckets so we can route
|
||||
# commander vs non-commander damage through _apply_damage separately.
|
||||
commander_damage = 0 # damage dealt by commander creatures
|
||||
normal_damage = 0 # damage dealt by non-commander creatures
|
||||
used_blockers: set[int] = set()
|
||||
combat_details: list[str] = []
|
||||
lifelink_total = 0 # lifelink healing for the attacker
|
||||
for atk in attackers:
|
||||
a_pow = atk.get_power()
|
||||
a_tou = atk.get_toughness()
|
||||
@@ -607,34 +658,52 @@ class GameEngine:
|
||||
trample_over = 0
|
||||
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
|
||||
trample_over = a_pow - blocker.get_toughness()
|
||||
total_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
total_damage += a_pow
|
||||
if atk.is_commander:
|
||||
commander_damage += trample_over
|
||||
else:
|
||||
total_damage += trample_over
|
||||
normal_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
|
||||
if atk.is_commander:
|
||||
commander_damage += ds_dmg
|
||||
else:
|
||||
normal_damage += ds_dmg
|
||||
if atk.has_keyword("lifelink"):
|
||||
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
damage_dealt *= 2
|
||||
p.life += damage_dealt
|
||||
lifelink_total += damage_dealt
|
||||
if not blocked:
|
||||
if atk.has_keyword("double strike"):
|
||||
total_damage += a_pow * 2
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow * 2
|
||||
atk_dmg = a_pow * 2
|
||||
else:
|
||||
total_damage += a_pow
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow
|
||||
opp.life -= total_damage
|
||||
p.stats.damage_dealt += total_damage
|
||||
opp.stats.damage_received += total_damage
|
||||
atk_dmg = a_pow
|
||||
if atk.is_commander:
|
||||
commander_damage += atk_dmg
|
||||
else:
|
||||
normal_damage += atk_dmg
|
||||
if atk.has_keyword("lifelink"):
|
||||
lifelink_total += atk_dmg
|
||||
|
||||
# Route all player damage through the centralized path.
|
||||
# Commander damage is applied first (tracks 21-damage rule).
|
||||
total_damage = 0
|
||||
if commander_damage > 0:
|
||||
total_damage += self._apply_damage(
|
||||
sim, commander_damage, opp_idx, pi,
|
||||
source_is_commander=True, is_combat=True,
|
||||
)
|
||||
if normal_damage > 0:
|
||||
total_damage += self._apply_damage(
|
||||
sim, normal_damage, opp_idx, pi,
|
||||
source_is_commander=False, is_combat=True,
|
||||
)
|
||||
# Apply lifelink healing
|
||||
if lifelink_total > 0:
|
||||
p.life += lifelink_total
|
||||
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,
|
||||
|
||||
@@ -152,6 +152,11 @@ class Player:
|
||||
# p.commander_tax[name] = tax + 2
|
||||
commander_tax: dict = field(default_factory=dict)
|
||||
|
||||
# Commander damage received from each opponent seat.
|
||||
# Keyed by opponent seat index; value is cumulative damage.
|
||||
# A player is eliminated when any single value reaches 21.
|
||||
commander_damage_received: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
stats: PlayerStats = field(default_factory=PlayerStats)
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ 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)
|
||||
@@ -73,8 +75,9 @@ class StateLogger:
|
||||
"""
|
||||
self._rotate()
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"snapshot_{timestamp}_t{game_state.turn}_s{seat}.json"
|
||||
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"
|
||||
filepath = self.log_dir / filename
|
||||
|
||||
snapshot = {
|
||||
|
||||
@@ -29,6 +29,7 @@ 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"
|
||||
@@ -66,6 +67,8 @@ 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)
|
||||
|
||||
+111
-3
@@ -26,11 +26,56 @@ 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 {
|
||||
@@ -47,7 +92,7 @@ class StubCard:
|
||||
@dataclass
|
||||
class StubPlayer:
|
||||
name: str
|
||||
seat: int
|
||||
seat: int = 0
|
||||
life: int = 40
|
||||
eliminated: bool = False
|
||||
hand: list = field(default_factory=list)
|
||||
@@ -75,7 +120,28 @@ 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,
|
||||
@@ -86,11 +152,27 @@ class StubGameState:
|
||||
self.turn = 1
|
||||
self.current_phase = "main1"
|
||||
self.active_player_seat = 0
|
||||
self.stack = []
|
||||
self.priority_seat = 0
|
||||
self.stack: list = []
|
||||
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"},
|
||||
@@ -106,6 +188,14 @@ 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
|
||||
@@ -121,6 +211,20 @@ 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
|
||||
@@ -157,8 +261,12 @@ 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
|
||||
return create_app()
|
||||
app = create_app()
|
||||
yield app
|
||||
SessionStore._instance = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -77,7 +77,7 @@ class TestDeckRatios:
|
||||
assert total == 99
|
||||
|
||||
def test_bad_total_raises(self):
|
||||
with pytest.raises(ValidationError, match="must sum to 99"):
|
||||
with pytest.raises(ValidationError, match="less_than_equal|must sum to 99"):
|
||||
DeckRatios(lands=50)
|
||||
|
||||
def test_out_of_range_raises(self):
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Regression tests for centralized commander damage tracking.
|
||||
============================================================
|
||||
Covers:
|
||||
- _apply_damage centralized path (life, stats, commander tracking, elimination)
|
||||
- Combat damage routing through _apply_damage (commander vs non-commander)
|
||||
- Commander damage 21-lethal elimination rule
|
||||
- Non-combat commander damage (direct _apply_damage calls)
|
||||
- Multi-seat / partner commander edge cases
|
||||
|
||||
Run with: pytest tests/sim/test_commander_damage.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_sim(num_players: int = 2, starting_life: int = 40) -> SimState:
|
||||
"""Create a minimal SimState with empty battlefields."""
|
||||
sim = SimState(max_turns=25)
|
||||
for i in range(num_players):
|
||||
sim.players.append(Player(
|
||||
name=f"P{i}",
|
||||
life=starting_life,
|
||||
owner_id=i,
|
||||
stats=PlayerStats(cards_drawn=0),
|
||||
))
|
||||
sim.init_battlefields(num_players)
|
||||
return sim
|
||||
|
||||
|
||||
def _creature(name: str, power: int, toughness: int, **kwargs) -> Card:
|
||||
"""Create a creature card with given stats."""
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
pt=f"{power}/{toughness}",
|
||||
power=str(power),
|
||||
toughness=str(toughness),
|
||||
**kwargs,
|
||||
)
|
||||
return enrich_card(c)
|
||||
|
||||
|
||||
def _land(name: str = "Forest") -> Card:
|
||||
c = Card(name=name, type_line="Basic Land — Forest", cmc=0)
|
||||
return enrich_card(c)
|
||||
|
||||
|
||||
def _place_on_battlefield(sim: SimState, seat: int, card: Card) -> Card:
|
||||
"""Place a card onto a seat's battlefield with a unique id."""
|
||||
card.owner_id = seat
|
||||
card.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
card.turn_played = -1 # played in a previous turn (not summoning sick)
|
||||
sim.add_to_battlefield(seat, card)
|
||||
return card
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _apply_damage unit tests
|
||||
# ===========================================================================
|
||||
|
||||
class TestApplyDamage:
|
||||
"""Tests for the centralized _apply_damage static method."""
|
||||
|
||||
def test_basic_damage_reduces_life(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert applied == 5
|
||||
assert sim.players[1].life == 35
|
||||
assert sim.players[1].stats.damage_received == 5
|
||||
assert sim.players[0].stats.damage_dealt == 5
|
||||
|
||||
def test_zero_damage_is_noop(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, 0, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40
|
||||
|
||||
def test_negative_damage_is_noop(self):
|
||||
sim = _make_sim()
|
||||
applied = GameEngine._apply_damage(sim, -3, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40
|
||||
|
||||
def test_damage_to_eliminated_player_is_noop(self):
|
||||
sim = _make_sim()
|
||||
sim.players[1].eliminated = True
|
||||
applied = GameEngine._apply_damage(sim, 10, target_seat=1, source_seat=0)
|
||||
assert applied == 0
|
||||
assert sim.players[1].life == 40 # unchanged
|
||||
|
||||
def test_lethal_damage_eliminates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 40, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == 0
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_overkill_damage_eliminates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 50, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == -10
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_non_commander_damage_does_not_track_commander_damage(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=1, source_seat=0,
|
||||
source_is_commander=False,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
|
||||
def test_commander_damage_tracks_per_seat(self):
|
||||
sim = _make_sim(num_players=3)
|
||||
GameEngine._apply_damage(
|
||||
sim, 7, target_seat=2, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 5, target_seat=2, source_seat=1,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[2].commander_damage_received == {0: 7, 1: 5}
|
||||
assert sim.players[2].life == 40 - 7 - 5
|
||||
|
||||
def test_commander_damage_accumulates(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 8, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 18
|
||||
|
||||
def test_21_commander_damage_eliminates(self):
|
||||
"""Commander damage rule: 21+ from a single source eliminates."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100 # high life — shouldn't save them
|
||||
GameEngine._apply_damage(
|
||||
sim, 21, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].life == 79 # life was reduced but still positive
|
||||
|
||||
def test_incremental_commander_damage_to_21_eliminates(self):
|
||||
"""Gradual commander damage reaching 21 triggers elimination."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100
|
||||
for _ in range(3):
|
||||
GameEngine._apply_damage(
|
||||
sim, 7, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_split_commander_damage_from_two_sources_no_elimination(self):
|
||||
"""20 commander damage from two different sources should NOT eliminate."""
|
||||
sim = _make_sim(num_players=3)
|
||||
sim.players[2].life = 100
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=2, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
GameEngine._apply_damage(
|
||||
sim, 10, target_seat=2, source_seat=1,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[2].commander_damage_received == {0: 10, 1: 10}
|
||||
assert sim.players[2].eliminated is False
|
||||
|
||||
def test_stats_update_on_both_players(self):
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(sim, 12, target_seat=1, source_seat=0)
|
||||
assert sim.players[0].stats.damage_dealt == 12
|
||||
assert sim.players[1].stats.damage_received == 12
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Combat damage routing through _apply_damage
|
||||
# ===========================================================================
|
||||
|
||||
class TestCombatCommanderDamageRouting:
|
||||
"""Verify _resolve_combat routes commander creature damage correctly."""
|
||||
|
||||
def _setup_combat(self, *, commander_power=5, defender_life=40):
|
||||
"""Set up a 2-player sim with one commander attacker and no blockers."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2, starting_life=defender_life)
|
||||
# Attacker: a commander creature
|
||||
cmd = _creature("Prossh", commander_power, 5, is_commander=True)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
# Defender: only lands (no blockers)
|
||||
for _ in range(3):
|
||||
_place_on_battlefield(sim, 1, _land())
|
||||
return engine, sim
|
||||
|
||||
def test_unblocked_commander_tracks_damage(self):
|
||||
engine, sim = self._setup_combat(commander_power=5)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 5
|
||||
assert sim.players[1].life == 35
|
||||
|
||||
def test_unblocked_noncommander_does_not_track(self):
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
# Non-commander attacker
|
||||
bear = _creature("Grizzly Bears", 2, 2)
|
||||
_place_on_battlefield(sim, 0, bear)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
assert sim.players[1].life == 38
|
||||
|
||||
def test_commander_combat_21_eliminates(self):
|
||||
"""Commander dealing 21+ combat damage in one hit should eliminate."""
|
||||
engine, sim = self._setup_combat(commander_power=21, defender_life=100)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].eliminated is True
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
|
||||
def test_mixed_commander_and_noncommander_attackers(self):
|
||||
"""Commander + non-commander attack together; only commander damage tracked."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
cmd = _creature("Prossh", 3, 3, is_commander=True)
|
||||
bear = _creature("Grizzly Bears", 2, 2)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
_place_on_battlefield(sim, 0, bear)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
# Total damage: 3 + 2 = 5
|
||||
assert sim.players[1].life == 35
|
||||
# Only commander damage tracked
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 3
|
||||
|
||||
def test_commander_trample_over_blocker_tracks_damage(self):
|
||||
"""Trample damage from a commander going over a blocker should track."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
# Low life forces the AI to assign a blocker via the
|
||||
# "opp.life <= a_pow * 2" heuristic even when toughness < power.
|
||||
sim = _make_sim(num_players=2, starting_life=10)
|
||||
cmd = _creature("Prossh", 7, 7, is_commander=True, keywords=["trample"])
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
blocker = _creature("Wall", 0, 3)
|
||||
_place_on_battlefield(sim, 1, blocker)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
# Trample over: 7 - 3 = 4 damage to player (all from commander)
|
||||
assert sim.players[1].commander_damage_received.get(0, 0) == 4
|
||||
assert sim.players[1].life == 6
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Non-combat commander damage (direct _apply_damage usage)
|
||||
# ===========================================================================
|
||||
|
||||
class TestNonCombatCommanderDamage:
|
||||
"""
|
||||
Validates that _apply_damage correctly handles non-combat commander damage
|
||||
scenarios. These represent cases like commander abilities, ETB triggers,
|
||||
or spell effects that deal damage where the source is a commander.
|
||||
"""
|
||||
|
||||
def test_direct_commander_ability_damage(self):
|
||||
"""Simulates a commander dealing direct damage (e.g. Purphoros trigger)."""
|
||||
sim = _make_sim(num_players=4)
|
||||
# Commander at seat 0 deals 2 to each opponent via ability
|
||||
for target in [1, 2, 3]:
|
||||
GameEngine._apply_damage(
|
||||
sim, 2, target_seat=target, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
for target in [1, 2, 3]:
|
||||
assert sim.players[target].commander_damage_received[0] == 2
|
||||
assert sim.players[target].life == 38
|
||||
|
||||
def test_repeated_noncombat_commander_damage_reaches_21(self):
|
||||
"""Repeated non-combat commander triggers accumulate to lethal 21."""
|
||||
sim = _make_sim()
|
||||
sim.players[1].life = 100
|
||||
# 11 triggers of 2 damage each = 22 total
|
||||
for _ in range(11):
|
||||
GameEngine._apply_damage(
|
||||
sim, 2, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 22
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_noncombat_noncommander_spell_damage(self):
|
||||
"""Non-commander spell damage: reduces life, no commander tracking."""
|
||||
sim = _make_sim()
|
||||
GameEngine._apply_damage(
|
||||
sim, 3, target_seat=1, source_seat=0,
|
||||
source_is_commander=False,
|
||||
)
|
||||
assert sim.players[1].life == 37
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
|
||||
def test_mixed_combat_and_noncombat_commander_damage(self):
|
||||
"""Both combat and non-combat commander damage should accumulate together."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
sim = _make_sim(num_players=2)
|
||||
sim.players[1].life = 100
|
||||
|
||||
# Non-combat commander damage: 15
|
||||
GameEngine._apply_damage(
|
||||
sim, 15, target_seat=1, source_seat=0,
|
||||
source_is_commander=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 15
|
||||
assert sim.players[1].eliminated is False
|
||||
|
||||
# Combat commander damage: 6 (pushes to 21)
|
||||
cmd = _creature("Prossh", 6, 6, is_commander=True)
|
||||
_place_on_battlefield(sim, 0, cmd)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].commander_damage_received[0] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Full game integration
|
||||
# ===========================================================================
|
||||
|
||||
class TestFullGameCommanderDamage:
|
||||
"""Integration test: run a full game with commanders and verify tracking."""
|
||||
|
||||
def _make_commander_deck(self, commander_name: str, seed: int = 42) -> list[Card]:
|
||||
"""Build a small test deck with a named commander."""
|
||||
import random
|
||||
rng = random.Random(seed)
|
||||
cards = []
|
||||
# Commander card
|
||||
cmd = Card(
|
||||
name=commander_name,
|
||||
type_line="Legendary Creature",
|
||||
cmc=5,
|
||||
pt="5/5",
|
||||
power="5",
|
||||
toughness="5",
|
||||
)
|
||||
cards.append(enrich_card(cmd))
|
||||
# Fill with lands and creatures
|
||||
for i in range(24):
|
||||
cards.append(enrich_card(Card(
|
||||
name=f"Forest_{i}",
|
||||
type_line="Basic Land — Forest",
|
||||
cmc=0,
|
||||
)))
|
||||
for i in range(35):
|
||||
cards.append(enrich_card(Card(
|
||||
name=f"Bear_{i}",
|
||||
type_line="Creature — Bear",
|
||||
cmc=2,
|
||||
pt="2/2",
|
||||
power="2",
|
||||
toughness="2",
|
||||
)))
|
||||
rng.shuffle(cards)
|
||||
return cards
|
||||
|
||||
def test_commander_damage_tracked_in_full_game(self):
|
||||
"""In a full game with commanders, commander_damage_received is populated."""
|
||||
engine = GameEngine(max_turns=15)
|
||||
deck_a = self._make_commander_deck("Prossh, Skyraider of Kher", seed=1)
|
||||
deck_b = self._make_commander_deck("Kaalia of the Vast", seed=2)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
commander_names=["Prossh, Skyraider of Kher", "Kaalia of the Vast"],
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
# The game completed without errors — commander damage tracking is wired
|
||||
+25
-24
@@ -29,65 +29,66 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
|
||||
|
||||
class TestGameEngineTwoPlayer:
|
||||
def test_game_completes(self):
|
||||
"""A 2-player seeded game should finish without error."""
|
||||
engine = GameEngine(seed=42)
|
||||
"""A 2-player game should finish without error."""
|
||||
engine = GameEngine()
|
||||
deck_a = _make_deck(seed=1)
|
||||
deck_b = _make_deck(seed=2)
|
||||
result = engine.run_game(
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
player_a_name="Alice",
|
||||
player_b_name="Bob",
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
|
||||
def test_result_has_winner_or_draw(self):
|
||||
"""winner_seat must be 0, 1, or -1 (draw)."""
|
||||
engine = GameEngine(seed=99)
|
||||
engine = GameEngine()
|
||||
deck = _make_deck(seed=5)
|
||||
result = engine.run_game(
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
player_a_name="P1",
|
||||
player_b_name="P2",
|
||||
name_a="P1",
|
||||
name_b="P2",
|
||||
)
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_seeded_game_is_deterministic(self):
|
||||
"""Same seed must produce identical results."""
|
||||
"""Same engine config must produce identical results with same decks."""
|
||||
deck_a = _make_deck(seed=7)
|
||||
deck_b = _make_deck(seed=8)
|
||||
|
||||
engine1 = GameEngine(seed=42)
|
||||
result1 = engine1.run_game(
|
||||
engine1 = GameEngine()
|
||||
result1 = engine1.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
player_a_name="Alice",
|
||||
player_b_name="Bob",
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
)
|
||||
|
||||
engine2 = GameEngine(seed=42)
|
||||
result2 = engine2.run_game(
|
||||
engine2 = GameEngine()
|
||||
result2 = engine2.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
player_a_name="Alice",
|
||||
player_b_name="Bob",
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
)
|
||||
|
||||
assert result1.winner_seat == result2.winner_seat
|
||||
assert result1.turns == result2.turns
|
||||
# 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)
|
||||
|
||||
def test_to_dict_serializable(self):
|
||||
"""Game result must serialize to a dict with expected keys."""
|
||||
import json
|
||||
engine = GameEngine(seed=77)
|
||||
engine = GameEngine()
|
||||
deck = _make_deck(seed=3)
|
||||
result = engine.run_game(
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
player_a_name="A",
|
||||
player_b_name="B",
|
||||
name_a="A",
|
||||
name_b="B",
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert "winner" in d
|
||||
|
||||
+22
-11
@@ -48,10 +48,20 @@ 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"}]
|
||||
|
||||
@@ -60,7 +70,7 @@ class _GS:
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -72,14 +82,13 @@ class BenchThreatAssessor:
|
||||
MIN_OPS_PER_SEC = 500
|
||||
|
||||
def test_assess_threats_throughput(self):
|
||||
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
gs = _GS()
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(self.ITERATIONS):
|
||||
for seat in range(4):
|
||||
ta.assess_threats(viewer_seat=seat)
|
||||
assess_threats(gs, viewer_seat=seat)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
ops = self.ITERATIONS * 4
|
||||
@@ -90,13 +99,12 @@ class BenchThreatAssessor:
|
||||
)
|
||||
|
||||
def test_top_threat_throughput(self):
|
||||
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
||||
from commander_ai_lab.sim.threat_assessor import top_threat
|
||||
gs = _GS()
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(self.ITERATIONS):
|
||||
ta.top_threat(viewer_seat=0)
|
||||
top_threat(gs, viewer_seat=0)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
ops_per_sec = self.ITERATIONS / elapsed
|
||||
@@ -236,20 +244,23 @@ 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 ThreatAssessor
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
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()
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
|
||||
def _threat_dict(viewer_seat: int) -> dict[int, float]:
|
||||
return {s.seat: s.total for s in assess_threats(gs, viewer_seat=viewer_seat)}
|
||||
|
||||
mem = TargetingMemory(4)
|
||||
ch = PoliticsCommsChannel()
|
||||
pe = PoliticsEngine(
|
||||
4, mem, ch,
|
||||
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
|
||||
lambda s: ta.assess_threats(viewer_seat=s),
|
||||
_threat_dict,
|
||||
)
|
||||
|
||||
async def simulate():
|
||||
@@ -258,7 +269,7 @@ class BenchIntegrated:
|
||||
active = turn % 4
|
||||
# Assess threats for all seats
|
||||
for seat in range(4):
|
||||
ta.assess_threats(viewer_seat=seat)
|
||||
assess_threats(gs, viewer_seat=seat)
|
||||
# Record a simulated attack
|
||||
attacker = active
|
||||
defender = (active + 1) % 4
|
||||
|
||||
+52
-16
@@ -25,7 +25,7 @@ import pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,15 +42,38 @@ 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"}]
|
||||
@@ -70,13 +93,17 @@ class TestThreatPoliticsIntegration:
|
||||
"""
|
||||
|
||||
def _build(self):
|
||||
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
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()
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
|
||||
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)}
|
||||
|
||||
mem = TargetingMemory(num_players=4)
|
||||
ch = PoliticsCommsChannel()
|
||||
pe = PoliticsEngine(
|
||||
@@ -84,19 +111,26 @@ class TestThreatPoliticsIntegration:
|
||||
memory=mem,
|
||||
comms=ch,
|
||||
personalities={0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
|
||||
threat_fn=lambda s: ta.assess_threats(viewer_seat=s),
|
||||
threat_fn=_threat_dict,
|
||||
)
|
||||
return gs, ta, mem, ch, pe
|
||||
return gs, mem, ch, pe
|
||||
|
||||
def test_adjusted_threat_no_spite_equals_base(self):
|
||||
gs, ta, mem, ch, pe = self._build()
|
||||
base = ta.assess_threats(viewer_seat=0).get(1, 0.0)
|
||||
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
|
||||
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, ta, mem, ch, pe = self._build()
|
||||
gs, 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)
|
||||
@@ -104,7 +138,7 @@ class TestThreatPoliticsIntegration:
|
||||
|
||||
def test_memory_aggression_raises_adjusted_score(self):
|
||||
from commander_ai_lab.sim.politics.memory import ActionType
|
||||
gs, ta, mem, ch, pe = self._build()
|
||||
gs, mem, ch, pe = self._build()
|
||||
before = pe.adjusted_threat_score(0, 2, 1)
|
||||
for _ in range(5):
|
||||
mem.record(1, 2, 0, ActionType.ATTACKED)
|
||||
@@ -112,12 +146,14 @@ class TestThreatPoliticsIntegration:
|
||||
assert after > before
|
||||
|
||||
def test_top_threat_matches_assessor(self):
|
||||
gs, ta, mem, ch, pe = self._build()
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
gs, 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 = ta.assess_threats(viewer_seat=0)
|
||||
# Just confirm assess_threats returns 3 keys for a 4-player game
|
||||
assert len(threats) == 3
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -279,7 +315,7 @@ class TestTurnManagerIntegration:
|
||||
on_thinking=on_thinking,
|
||||
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=2),
|
||||
)
|
||||
asyncio.get_event_loop().run_until_complete(tm.run_game())
|
||||
asyncio.run(tm.run_game())
|
||||
# Game should have fired at least one event
|
||||
assert len(events) >= 1
|
||||
|
||||
@@ -307,7 +343,7 @@ class TestTurnManagerIntegration:
|
||||
on_thinking=on_thinking,
|
||||
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=1),
|
||||
)
|
||||
asyncio.get_event_loop().run_until_complete(tm.run_game())
|
||||
asyncio.run(tm.run_game())
|
||||
# If phases were emitted, they should start with untap
|
||||
if phases_seen:
|
||||
assert phases_seen[0] in ("untap", "upkeep", "draw", "main1")
|
||||
@@ -337,7 +373,7 @@ class TestTurnManagerIntegration:
|
||||
on_thinking=on_thinking,
|
||||
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=3),
|
||||
)
|
||||
asyncio.get_event_loop().run_until_complete(tm.run_game())
|
||||
asyncio.run(tm.run_game())
|
||||
assert game_over_fired[0] is True
|
||||
|
||||
|
||||
|
||||
+65
-4
@@ -39,6 +39,34 @@ 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:
|
||||
@@ -46,11 +74,35 @@ 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."""
|
||||
@@ -62,6 +114,14 @@ 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)
|
||||
@@ -100,12 +160,13 @@ def _make_tm(
|
||||
class TestThreatAssessor:
|
||||
def _gs_with_creatures(self) -> _StubGameState:
|
||||
gs = _stub_gs(4)
|
||||
# Give seat 0 a big creature
|
||||
# Give seat 0 a big creature — use Card from sim.models for full API compat
|
||||
from commander_ai_lab.sim.models import Card
|
||||
gs.players[0].battlefield = [
|
||||
{"name": "Dragon", "type": "creature", "pt": "6/6", "oracle": ""},
|
||||
{"name": "Forest", "type": "land", "pt": "", "oracle": ""},
|
||||
Card(name="Dragon", type_line="Creature — Dragon", pt="6/6", power="6", toughness="6"),
|
||||
Card(name="Forest", type_line="Basic Land — Forest"),
|
||||
]
|
||||
gs.players[1].hand = [{"name": f"Card{i}"} for i in range(7)]
|
||||
gs.players[1].hand = [Card(name=f"Card{i}") for i in range(7)]
|
||||
return gs
|
||||
|
||||
def test_returns_score_per_non_eliminated_player(self):
|
||||
|
||||
+12
-3
@@ -15,12 +15,21 @@ 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()
|
||||
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
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.get_event_loop().run_until_complete(coro)
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -217,7 +217,7 @@ class TestPoliticsCommsChannel:
|
||||
async def h(msg): received_seats.append(s)
|
||||
return h
|
||||
import asyncio
|
||||
h = asyncio.get_event_loop().run_until_complete(make_handler())
|
||||
h = asyncio.run(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.get_event_loop().run_until_complete(coro)
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user