Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a88b371889 | ||
|
|
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:
|
||||
|
||||
@@ -483,6 +483,52 @@ class GameEngine:
|
||||
)
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized damage routing
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _apply_damage(
|
||||
sim: SimState,
|
||||
source_card: Card | None,
|
||||
source_seat: int,
|
||||
target_player: Player,
|
||||
amount: int,
|
||||
) -> None:
|
||||
"""Route damage through a single path that handles commander tracking.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sim : SimState
|
||||
The current game state.
|
||||
source_card : Card | None
|
||||
The card that is the source of the damage (used for commander
|
||||
identification). May be ``None`` for non-card damage sources.
|
||||
source_seat : int
|
||||
Seat index of the player who controls the damage source.
|
||||
target_player : Player
|
||||
The player receiving the damage.
|
||||
amount : int
|
||||
Amount of damage to deal. Zero or negative is a no-op.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return
|
||||
|
||||
target_player.life -= amount
|
||||
target_player.stats.damage_received += amount
|
||||
|
||||
# Track commander damage (MTG rule 903.10a)
|
||||
if source_card is not None and source_card.is_commander:
|
||||
target_player.commander_damage_received[source_seat] = (
|
||||
target_player.commander_damage_received.get(source_seat, 0) + amount
|
||||
)
|
||||
|
||||
# Check lethal: life <= 0 or commander damage >= 21
|
||||
if target_player.life <= 0:
|
||||
target_player.eliminated = True
|
||||
elif any(v >= 21 for v in target_player.commander_damage_received.values()):
|
||||
target_player.eliminated = True
|
||||
|
||||
@staticmethod
|
||||
def _select_attack_target(sim: SimState, pi: int) -> int:
|
||||
"""Select the best opponent to attack.
|
||||
@@ -572,6 +618,7 @@ class GameEngine:
|
||||
)
|
||||
]
|
||||
blocked = False
|
||||
atk_player_damage = 0 # damage this attacker deals to the opponent player
|
||||
if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2):
|
||||
if atk.has_keyword("menace") and len(valid_blockers) >= 2:
|
||||
valid_blockers.sort(key=lambda b: -b.get_toughness())
|
||||
@@ -607,12 +654,12 @@ 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
|
||||
atk_player_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
total_damage += a_pow
|
||||
atk_player_damage += a_pow
|
||||
else:
|
||||
total_damage += trample_over
|
||||
atk_player_damage += trample_over
|
||||
if atk.has_keyword("lifelink"):
|
||||
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
@@ -620,21 +667,21 @@ class GameEngine:
|
||||
p.life += damage_dealt
|
||||
if not blocked:
|
||||
if atk.has_keyword("double strike"):
|
||||
total_damage += a_pow * 2
|
||||
atk_player_damage += a_pow * 2
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow * 2
|
||||
else:
|
||||
total_damage += a_pow
|
||||
atk_player_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
|
||||
# Route this attacker's damage through the centralized damage path
|
||||
if atk_player_damage > 0:
|
||||
self._apply_damage(sim, atk, pi, opp, atk_player_damage)
|
||||
p.stats.damage_dealt += atk_player_damage
|
||||
total_damage += atk_player_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,
|
||||
|
||||
@@ -95,11 +95,17 @@ class CommanderPlayer:
|
||||
# Whether this player has drawn their card this turn
|
||||
drawn_this_turn: bool = False
|
||||
|
||||
# Commander damage received from each opponent (keyed by seat index)
|
||||
commander_damage_received: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# ── Convenience pass-throughs to base Player ─────────────
|
||||
|
||||
@property
|
||||
def commander_damage_received(self) -> dict[int, int]:
|
||||
"""Delegate to base Player's commander_damage_received."""
|
||||
return self.base.commander_damage_received
|
||||
|
||||
@commander_damage_received.setter
|
||||
def commander_damage_received(self, value: dict[int, int]) -> None:
|
||||
self.base.commander_damage_received = value
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.base.name
|
||||
|
||||
@@ -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 = cumulative commander damage.
|
||||
# A player loses when any single value reaches 21+ (MTG rule 903.10a).
|
||||
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,342 @@
|
||||
"""
|
||||
Regression tests for centralized damage routing (Issue #86).
|
||||
|
||||
Covers:
|
||||
- _apply_damage() source-aware routing
|
||||
- Commander damage accumulation via combat
|
||||
- Commander damage elimination at 21+
|
||||
- Regular (non-commander) combat damage does NOT track commander damage
|
||||
- Trample overflow from commanders tracks correctly
|
||||
- Double strike commander damage doubled correctly
|
||||
- Lifelink healing alongside commander damage tracking
|
||||
- Multi-attacker mixed commander + non-commander combat
|
||||
- Full game with commander damage kill
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _make_creature(
|
||||
name: str = "Bear",
|
||||
power: int = 2,
|
||||
toughness: int = 2,
|
||||
keywords: list[str] | None = None,
|
||||
is_commander: bool = False,
|
||||
card_id: int = 1,
|
||||
owner_id: int = 0,
|
||||
turn_played: int = -1,
|
||||
) -> Card:
|
||||
"""Create a creature card for testing."""
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
pt=f"{power}/{toughness}",
|
||||
power=str(power),
|
||||
toughness=str(toughness),
|
||||
keywords=keywords or [],
|
||||
is_commander=is_commander,
|
||||
id=card_id,
|
||||
owner_id=owner_id,
|
||||
turn_played=turn_played,
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
def _two_player_sim(
|
||||
p0_life: int = 40,
|
||||
p1_life: int = 40,
|
||||
p0_battlefield: list[Card] | None = None,
|
||||
p1_battlefield: list[Card] | None = None,
|
||||
) -> SimState:
|
||||
"""Create a minimal 2-player SimState for damage tests."""
|
||||
sim = SimState()
|
||||
sim.init_battlefields(2)
|
||||
sim.players.append(Player(name="Attacker", life=p0_life, owner_id=0, stats=PlayerStats()))
|
||||
sim.players.append(Player(name="Defender", life=p1_life, owner_id=1, stats=PlayerStats()))
|
||||
for c in (p0_battlefield or []):
|
||||
sim.add_to_battlefield(0, c)
|
||||
for c in (p1_battlefield or []):
|
||||
sim.add_to_battlefield(1, c)
|
||||
return sim
|
||||
|
||||
|
||||
# ── _apply_damage unit tests ─────────────────────────────────
|
||||
|
||||
class TestApplyDamage:
|
||||
"""Direct tests of the _apply_damage static method."""
|
||||
|
||||
def test_regular_creature_damage_reduces_life(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", power=2, toughness=2, is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 5)
|
||||
assert target.life == 35
|
||||
assert target.stats.damage_received == 5
|
||||
|
||||
def test_regular_creature_no_commander_damage_tracked(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 10)
|
||||
assert target.commander_damage_received == {}
|
||||
|
||||
def test_commander_damage_tracked(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=4, toughness=4, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 4)
|
||||
assert target.commander_damage_received == {0: 4}
|
||||
assert target.life == 36
|
||||
|
||||
def test_commander_damage_accumulates(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=4, toughness=4, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 10)
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 10)
|
||||
assert target.commander_damage_received == {0: 20}
|
||||
assert target.life == 20
|
||||
assert not target.eliminated # 20 < 21
|
||||
|
||||
def test_commander_damage_21_eliminates(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=4, toughness=4, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 21)
|
||||
assert target.eliminated
|
||||
assert target.commander_damage_received == {0: 21}
|
||||
assert target.life == 19 # still above 0 but eliminated by cmdr damage
|
||||
|
||||
def test_commander_damage_over_21_eliminates(self):
|
||||
sim = _two_player_sim()
|
||||
commander = _make_creature("Atraxa", power=7, toughness=7, is_commander=True)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 10)
|
||||
GameEngine._apply_damage(sim, commander, 0, target, 12)
|
||||
assert target.eliminated
|
||||
assert target.commander_damage_received == {0: 22}
|
||||
|
||||
def test_life_lethal_eliminates(self):
|
||||
sim = _two_player_sim(p1_life=5)
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 5)
|
||||
assert target.eliminated
|
||||
assert target.life == 0
|
||||
|
||||
def test_zero_damage_is_noop(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, 0)
|
||||
assert target.life == 40
|
||||
assert target.stats.damage_received == 0
|
||||
|
||||
def test_negative_damage_is_noop(self):
|
||||
sim = _two_player_sim()
|
||||
bear = _make_creature("Bear", is_commander=False)
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, bear, 0, target, -3)
|
||||
assert target.life == 40
|
||||
|
||||
def test_none_source_card_no_commander_tracking(self):
|
||||
"""Damage with no source card should not track commander damage."""
|
||||
sim = _two_player_sim()
|
||||
target = sim.players[1]
|
||||
GameEngine._apply_damage(sim, None, 0, target, 5)
|
||||
assert target.life == 35
|
||||
assert target.commander_damage_received == {}
|
||||
|
||||
def test_multiple_commanders_tracked_separately(self):
|
||||
"""Commander damage from different seats tracked independently."""
|
||||
sim = SimState()
|
||||
sim.init_battlefields(3)
|
||||
for i in range(3):
|
||||
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i, stats=PlayerStats()))
|
||||
target = sim.players[2]
|
||||
cmd_a = _make_creature("Atraxa", is_commander=True)
|
||||
cmd_b = _make_creature("Korvold", is_commander=True)
|
||||
GameEngine._apply_damage(sim, cmd_a, 0, target, 15)
|
||||
GameEngine._apply_damage(sim, cmd_b, 1, target, 10)
|
||||
assert target.commander_damage_received == {0: 15, 1: 10}
|
||||
assert target.life == 15
|
||||
assert not target.eliminated # neither source at 21
|
||||
|
||||
def test_one_of_multiple_commanders_reaches_21(self):
|
||||
"""Elimination triggers when one opponent's commander damage hits 21."""
|
||||
sim = SimState()
|
||||
sim.init_battlefields(3)
|
||||
for i in range(3):
|
||||
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i, stats=PlayerStats()))
|
||||
target = sim.players[2]
|
||||
cmd_a = _make_creature("Atraxa", is_commander=True)
|
||||
cmd_b = _make_creature("Korvold", is_commander=True)
|
||||
GameEngine._apply_damage(sim, cmd_a, 0, target, 21)
|
||||
assert target.eliminated
|
||||
# Second commander's damage is still at 0
|
||||
assert target.commander_damage_received.get(1, 0) == 0
|
||||
|
||||
|
||||
# ── Combat integration tests ────────────────────────────────
|
||||
|
||||
class TestCombatCommanderDamage:
|
||||
"""Integration tests: commander damage tracking through _resolve_combat."""
|
||||
|
||||
def _run_combat(
|
||||
self,
|
||||
attackers: list[Card],
|
||||
blockers: list[Card] | None = None,
|
||||
p0_life: int = 40,
|
||||
p1_life: int = 40,
|
||||
) -> tuple[SimState, GameEngine]:
|
||||
"""Set up and run a single combat step."""
|
||||
sim = _two_player_sim(
|
||||
p0_life=p0_life,
|
||||
p1_life=p1_life,
|
||||
p0_battlefield=attackers,
|
||||
p1_battlefield=blockers or [],
|
||||
)
|
||||
sim.turn = 1 # attackers need turn_played < sim.turn
|
||||
engine = GameEngine()
|
||||
engine._resolve_combat(sim, pi=0, turn=1)
|
||||
return sim, engine
|
||||
|
||||
def test_unblocked_commander_tracks_damage(self):
|
||||
cmd = _make_creature("Atraxa", power=4, toughness=4, is_commander=True, card_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 36
|
||||
assert defender.commander_damage_received == {0: 4}
|
||||
|
||||
def test_unblocked_regular_creature_no_commander_damage(self):
|
||||
bear = _make_creature("Bear", power=3, toughness=3, is_commander=False, card_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([bear])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 37
|
||||
assert defender.commander_damage_received == {}
|
||||
|
||||
def test_mixed_attackers_only_commander_tracked(self):
|
||||
"""When commander + regular creature attack, only commander damage is tracked."""
|
||||
cmd = _make_creature("Atraxa", power=4, toughness=4, is_commander=True, card_id=1, turn_played=0)
|
||||
bear = _make_creature("Bear", power=3, toughness=3, is_commander=False, card_id=2, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd, bear])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 33 # 40 - 4 - 3
|
||||
assert defender.commander_damage_received == {0: 4}
|
||||
|
||||
def test_commander_trample_overflow_tracked(self):
|
||||
"""Trample overflow from commander attack should be tracked as commander damage.
|
||||
|
||||
The AI blocking heuristic chump-blocks when opp.life <= a_pow * 2.
|
||||
With life=10 and attacker power=6 (10 <= 12), the 0/3 will chump block.
|
||||
Trample overflow = 6 - 3 = 3, tracked as commander damage.
|
||||
"""
|
||||
cmd = _make_creature(
|
||||
"Atraxa", power=6, toughness=6, is_commander=True,
|
||||
keywords=["trample"], card_id=1, turn_played=0,
|
||||
)
|
||||
blocker = _make_creature("Wall", power=0, toughness=3, card_id=10, owner_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd], [blocker], p1_life=10)
|
||||
defender = sim.players[1]
|
||||
trample_over = 6 - 3 # power - blocker toughness
|
||||
assert defender.commander_damage_received == {0: trample_over}
|
||||
assert defender.life == 10 - trample_over
|
||||
|
||||
def test_commander_double_strike_unblocked(self):
|
||||
"""Double strike commander deals 2x damage, all tracked as commander damage."""
|
||||
cmd = _make_creature(
|
||||
"Rafiq", power=3, toughness=3, is_commander=True,
|
||||
keywords=["double strike"], card_id=1, turn_played=0,
|
||||
)
|
||||
sim, _ = self._run_combat([cmd])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 34 # 40 - 3*2
|
||||
assert defender.commander_damage_received == {0: 6}
|
||||
|
||||
def test_commander_lifelink_heals_attacker(self):
|
||||
"""Lifelink on commander heals attacker while tracking commander damage."""
|
||||
cmd = _make_creature(
|
||||
"Oloro", power=4, toughness=4, is_commander=True,
|
||||
keywords=["lifelink"], card_id=1, turn_played=0,
|
||||
)
|
||||
sim, _ = self._run_combat([cmd], p0_life=30)
|
||||
attacker = sim.players[0]
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 36
|
||||
assert defender.commander_damage_received == {0: 4}
|
||||
assert attacker.life == 34 # healed 4
|
||||
|
||||
def test_blocked_commander_no_player_damage(self):
|
||||
"""Fully blocked commander with no trample deals no player damage."""
|
||||
cmd = _make_creature(
|
||||
"Atraxa", power=3, toughness=3, is_commander=True,
|
||||
card_id=1, turn_played=0,
|
||||
)
|
||||
blocker = _make_creature("Wall", power=0, toughness=5, card_id=10, owner_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd], [blocker])
|
||||
defender = sim.players[1]
|
||||
assert defender.life == 40
|
||||
assert defender.commander_damage_received == {}
|
||||
|
||||
def test_cumulative_combat_commander_damage_kills(self):
|
||||
"""Multiple combat steps accumulate commander damage to 21+."""
|
||||
sim = _two_player_sim(
|
||||
p0_battlefield=[
|
||||
_make_creature("Atraxa", power=7, toughness=7, is_commander=True, card_id=1, turn_played=0)
|
||||
],
|
||||
)
|
||||
sim.turn = 1
|
||||
engine = GameEngine()
|
||||
# Swing 3 times (7 * 3 = 21)
|
||||
for _ in range(3):
|
||||
# Untap attacker between swings
|
||||
for c in sim.get_battlefield(0):
|
||||
c.tapped = False
|
||||
engine._resolve_combat(sim, pi=0, turn=1)
|
||||
defender = sim.players[1]
|
||||
assert defender.commander_damage_received == {0: 21}
|
||||
assert defender.eliminated
|
||||
|
||||
def test_stats_damage_dealt_tracked(self):
|
||||
"""Attacker's damage_dealt stat is correctly updated."""
|
||||
cmd = _make_creature("Atraxa", power=5, toughness=5, is_commander=True, card_id=1, turn_played=0)
|
||||
sim, _ = self._run_combat([cmd])
|
||||
attacker = sim.players[0]
|
||||
assert attacker.stats.damage_dealt == 5
|
||||
|
||||
|
||||
# ── CommanderGameState compat tests ──────────────────────────
|
||||
|
||||
class TestGameStateCompat:
|
||||
"""Ensure CommanderGameState still works after Player model change."""
|
||||
|
||||
def test_commander_player_delegates_to_base(self):
|
||||
from commander_ai_lab.sim.game_state import CommanderPlayer
|
||||
p = Player(name="Test", life=40)
|
||||
cp = CommanderPlayer(base=p)
|
||||
cp.commander_damage_received[0] = 15
|
||||
assert p.commander_damage_received == {0: 15}
|
||||
|
||||
def test_commander_game_state_deal_damage(self):
|
||||
from commander_ai_lab.sim.game_state import CommanderGameState
|
||||
sim = SimState()
|
||||
sim.init_battlefields(2)
|
||||
sim.players.append(Player(name="P0", life=40, owner_id=0))
|
||||
sim.players.append(Player(name="P1", life=40, owner_id=1))
|
||||
gs = CommanderGameState.from_sim_state(sim)
|
||||
gs.deal_commander_damage(from_seat=0, to_seat=1, amount=10)
|
||||
assert gs.get_commander_damage(0, 1) == 10
|
||||
assert gs.commander_players[1].life == 30
|
||||
# Base Player also has the damage
|
||||
assert sim.players[1].commander_damage_received == {0: 10}
|
||||
|
||||
def test_is_dead_to_commander_damage_via_base(self):
|
||||
from commander_ai_lab.sim.game_state import CommanderPlayer
|
||||
p = Player(name="Test", life=40)
|
||||
cp = CommanderPlayer(base=p)
|
||||
p.commander_damage_received[0] = 21
|
||||
assert cp.is_dead_to_commander_damage()
|
||||
+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