Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2527b7cf98 | ||
|
|
47dde2c896 | ||
|
|
b2a25f7234 | ||
|
|
e0b538af6b | ||
|
|
f27fa61003 | ||
|
|
b07b59d206 | ||
|
|
52cbefaccf |
No files matched your search
+31
-22
@@ -1,27 +1,36 @@
|
||||
# Commander AI Lab — Environment Variables
|
||||
# Copy this file to .env and fill in your values.
|
||||
# start-lab.bat will auto-load .env if it exists.
|
||||
# Commander AI Lab - Environment Variables
|
||||
# Copy this file to .env and fill in your API keys.
|
||||
# .env is gitignored and will NOT be committed.
|
||||
|
||||
# ── API Keys ──────────────────────────────────────────────────────
|
||||
# Ximilar API Key (card scanner / visual AI recognition)
|
||||
# Get yours at: https://app.ximilar.com/
|
||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
|
||||
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
|
||||
# Get yours at: https://console.anthropic.com/settings/keys
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
XIMILAR_API_KEY=your-ximilar-key-here
|
||||
# Anthropic model for deck generation and coaching (Claude Opus recommended)
|
||||
ANTHROPIC_MODEL=claude-opus-4-20250514
|
||||
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
|
||||
COACH_PROVIDER=anthropic
|
||||
|
||||
# ── Forge JAR Path ────────────────────────────────────────────────
|
||||
# Point this to your Forge GUI desktop fat JAR.
|
||||
FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
# Add your LAN IP if accessing from another machine on your network
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||
|
||||
# IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target).
|
||||
# The JAR looks for res/ relative to its working directory. Without this, you get
|
||||
# ExceptionInInitializerError on startup.
|
||||
FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui
|
||||
# ── Dual-Model AI Brain ─────────────────────────────────────
|
||||
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
|
||||
BRAIN_API_BASE=http://localhost:11434
|
||||
BRAIN_MODEL=gpt-oss:20b
|
||||
BRAIN_TEMPERATURE=0.3
|
||||
BRAIN_MAX_TOKENS=1024
|
||||
BRAIN_TIMEOUT=120.0
|
||||
|
||||
# ── Deck Paths ────────────────────────────────────────────────────
|
||||
# Directory containing your precon .dck files.
|
||||
# Forge reads deck files directly from this folder.
|
||||
#
|
||||
# IMPORTANT: lab_api.py reads PRECON_DIR (not PRECON_DECKS_DIR).
|
||||
# PRECON_DECKS_DIR is kept below for backwards compat but is NOT used by the server.
|
||||
PRECON_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
|
||||
PRECON_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
|
||||
USER_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
|
||||
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\user-decks
|
||||
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
|
||||
VALIDATOR_ENABLED=true
|
||||
VALIDATOR_API_BASE=http://localhost:11434
|
||||
VALIDATOR_MODEL=deepseek-r1:14b
|
||||
VALIDATOR_TEMPERATURE=0.0
|
||||
VALIDATOR_MAX_TOKENS=2048
|
||||
VALIDATOR_TIMEOUT=120.0
|
||||
+29
-13
@@ -32,24 +32,40 @@ embeddings/
|
||||
checkpoints/
|
||||
imported-decks/
|
||||
|
||||
# User-created decks (generated at runtime, not committed)
|
||||
user-decks/*.dck
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Runtime artifacts / caches
|
||||
static/
|
||||
api/
|
||||
image_cache/
|
||||
data/rag_chroma/
|
||||
.claude/
|
||||
dependency-reduced-pom.xml
|
||||
# Precon decks — runtime data, downloaded locally by Forge/scripts
|
||||
# Do not commit .dck files; they are large and machine-generated
|
||||
precon-decks/*.dck
|
||||
precon-decks/precon-index.json
|
||||
|
||||
# Precon decks (generated / imported at runtime)
|
||||
precon-decks/
|
||||
# Phase 0: Local Scryfall card image cache (~1.5-2GB)
|
||||
# Downloaded by scripts/download_card_images.py — never commit
|
||||
static/card-images/
|
||||
|
||||
# Phase 0: SQLite image index (generated by scripts/build_image_index.py)
|
||||
data/card_image_index.db
|
||||
|
||||
# Phase 6: AI decision logs and training sets (large runtime data)
|
||||
data/decision_logs/
|
||||
data/game_results.jsonl
|
||||
data/training_sets/
|
||||
|
||||
# Unity generated / local config files
|
||||
unity-client/Packages/packages-lock.json
|
||||
unity-client/ProjectSettings/EditorBuildSettings.asset
|
||||
unity-client/ProjectSettings/EditorSettings.asset
|
||||
unity-client/ProjectSettings/ShaderGraphSettings.asset
|
||||
unity-commander-ui/Packages/packages-lock.json
|
||||
unity-commander-ui/ProjectSettings/EditorBuildSettings.asset
|
||||
unity-commander-ui/ProjectSettings/EditorSettings.asset
|
||||
|
||||
# Third-party Unity assets — installed locally via Unity Package Manager / Asset Store
|
||||
# These are large binary asset packs; each dev installs from their own license
|
||||
unity-client/Assets/Eric VFX Studio/
|
||||
unity-client/Assets/TcgEngine/
|
||||
@@ -1,93 +0,0 @@
|
||||
# FarmHouse changes
|
||||
|
||||
This branch (`farmhouse`) is `KoalaTrapLord/commander-ai-lab` at commit `54cc5e9` plus the changes below. They were made to run the app
|
||||
**Python-only** (no Forge/Java, no PyTorch) in a Kubernetes namespace, with a local LLM behind an OpenAI-compatible gateway instead of
|
||||
Anthropic/DeepSeek, and they were each found by actually running the app, not by reading it. Written 2026-10-26.
|
||||
|
||||
Every change started life as an anchored text patch (`patch_app.py` in the farmhouse-os repo, which still builds the deployed image from
|
||||
the upstream commit); here they are ordinary source edits. Lines carry a `# FARMHOUSE_...` marker where the patch left one.
|
||||
|
||||
## What was wrong, and what changed
|
||||
|
||||
### The simulator played nothing (the big one)
|
||||
| Problem | Change |
|
||||
|---|---|
|
||||
| Every card reached the simulator with **no type line and no rules text**. The loaders join the user's *collection* and a *card_records* table (both empty on a fresh install) and the Forge card cache that was meant to fill the gap is not installed. A land was indistinguishable from a creature: an 8-game batch cast 0 spells, dealt 0 damage and lost every game. | `services/farmhouse_sim.py` `fill_card_data` fills type, text, mana cost, cmc, P/T, keywords and colour identity from the 113k-card Scryfall bulk database. Wired into `services/forge_runner.py` (batch) and `routes/deepseek.py` (`_fetch_deck_card_data`, which the Monte Carlo and single-deck routes use). |
|
||||
| The batch loader **did not repeat cards by quantity**: a 100-card deck loaded as 66-84 cards with *one* Island. | `expand_quantities` (same module). |
|
||||
| The LLM engine asked the model **once per turn** and applied the answer as one action. | `src/commander_ai_lab/sim/farmhouse_turns.py`: up to `BRAIN_STEPS` (default 4) questions per turn (land, spell, spell, attack), re-reading the board after each; stops on attack/hold/no change. `BRAIN_STEPS=1` restores the original. |
|
||||
| A model that "thinks" spent ~46 s on a one-line move. | `deepseek_brain.py` sends `reasoning_effort` from `LLM_REASONING` (46 s -> 1.8 s per move). |
|
||||
| The engine dropdown defaulted to **Forge**, which cannot run without Java. | `ui/ai-lab.js`: only "LLM opponents" is offered. |
|
||||
|
||||
After the fixes: a 40-game Monte Carlo takes 0.4 s with real numbers (10 spells and 12.7 damage per game); two LLM-piloted games took
|
||||
212 s with 49 model calls and no fallbacks.
|
||||
|
||||
### Deck import
|
||||
| Problem | Change |
|
||||
|---|---|
|
||||
| **Moxfield links were accepted nowhere**, and the Deck Builder import took only pasted text. | `services/farmhouse_deck_links.py`: Moxfield (public decks, `api2.moxfield.com`) and Archidekt links in the simulator URL import and in both Deck Builder imports (a lone link in the paste box). A private deck says to use Moxfield's Export > Copy as text. |
|
||||
| The Deck Builder import looked **every card up on scryfall.com**; after a few dozen it was rate-limited (HTTP 429) and the app reported the rest as "not found" (250-card deck: 55 matched; even plain `Sol Ring` failed). | `local_card()`: names resolve from the local bulk database first; the website is only the fallback. |
|
||||
|
||||
### Deck generation
|
||||
- `deck_builder/pipeline/farmhouse_fit.py`: the model's list is **filtered to the commander's colour identity** (from the bulk DB), de-duplicated,
|
||||
fitted to per-category targets and trimmed to 99 + commander. Before this the model returned off-colour cards and 359-card lists.
|
||||
- Scryfall bulk format changed (`jsonl_download_uri`, gzip JSONL; no `download_uri`).
|
||||
- Scryfall answers **HTTP 400 to the default `python-requests` User-Agent** (every lookup became "Commander not found"): a named UA is sent everywhere.
|
||||
- Scryfall 429s: requests back off on `Retry-After` and the pace is 0.25 s.
|
||||
- `LLM_REASONING` / `LLM_MAX_TOKENS` are sent by the deck builder and coach.
|
||||
|
||||
### Coach / startup
|
||||
- The coach was only initialised in `main()`, not when the app starts under uvicorn; `check_connection_sync()` was called but never defined;
|
||||
the coach used the first model the gateway listed rather than the configured one. All fixed.
|
||||
- Status label says "Language model" instead of the hardcoded "Claude Opus".
|
||||
- `RAG_AUTOBUILD` (default off in our deployment) controls whether the card-search index is built at startup (113k embeddings; about an hour).
|
||||
|
||||
### Decision model (Laya / Kev), wired and off
|
||||
`src/commander_ai_lab/sim/farmhouse_decide.py` asks a `/v1/systemone` server which of the nine actions to take, in one forward pass.
|
||||
`DECISION_MODE=shadow` records whether it agrees with the LLM (the game still uses the LLM); `assist` uses it when `DECISION_MIN_CONF`
|
||||
sure; `off` (default) does nothing. `scripts/farmhouse_decision_eval.py` replays recorded decisions through it.
|
||||
|
||||
**Measured 2026-10-26 on 149 recorded LLM decisions: zero-shot, both Laya checkpoints agreed with the LLM less often than always
|
||||
guessing the commonest action** (English 16.1%, typed-decisions 18.6%, baseline 27.5%), at 2-4 s per answer on a 4-thread CPU. It is only
|
||||
worth using after fine-tuning on this game's decisions; the write-up is `LAYA-EVALUATION.md` in the farmhouse-os repo's commander-lab folder.
|
||||
|
||||
### Laya connections (all off by default)
|
||||
Forge's concrete priority path, extra typed questions in the same call, a capture log, a fine-tuning dataset exporter, and card-role classification:
|
||||
`src/commander_ai_lab/sim/farmhouse_laya.py`, `scripts/farmhouse_laya_dataset.py`, `scripts/farmhouse_laya_roles_eval.py`, tests `tests/farmhouse/test_farmhouse_laya*.py`.
|
||||
How it fits the strategic-intelligence work, what is measured and how to fine-tune: [docs/LAYA-CONNECTIONS.md](docs/LAYA-CONNECTIONS.md). Reference copies of the Laya
|
||||
image, manifests and CI are in `farmhouse/laya/`.
|
||||
|
||||
### Forge, and rules-accurate simulation with phase-rs
|
||||
- **Forge is installed** in the cluster image (it was left out by mistake): `farmhouse/forge/README.md`. Batches are clamped to one game at a time (`FORGE_MAX_THREADS`).
|
||||
- **Rules-accurate simulation** through the phase-rs engine (Rust, headless): `routes/farmhouse_phase.py`, `ui/phase-sim.html` (linked from the home page) and the service in
|
||||
`farmhouse/phase/`. Four saved decks play real Commander games; the page shows wins by seat with a 95% interval and warns about cards the engine did not recognise.
|
||||
Seats can be put on a **model** (twin-turbo through Olla, or Laya): the model chooses among the engine's own legal options only, anything wrong or slow is played by phase's AI,
|
||||
and a call cap bounds the cost (`farmhouse/phase/README.md`). Assessment and plan: `docs/PHASE-AND-PLAYTESTER.md`.
|
||||
|
||||
## Settings (environment variables)
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `LLM_URL`, `LLM_MODEL` | OpenAI-compatible endpoint (`.../v1`) and model for the coach and deck builder |
|
||||
| `LLM_REASONING` (`none`), `LLM_MAX_TOKENS`, `LLM_TIMEOUT` | keep requests lean: reasoning off, cap on answer length |
|
||||
| `COACH_PROVIDER`, `DECK_GEN_PROVIDER` = `local`, `DECK_GEN_MODEL` | use the local model, not Anthropic |
|
||||
| `OLLAMA_BASE_URL`, `RAG_EMBEDDING_MODEL`, `RAG_AUTOBUILD` | card-search embeddings (one host only: vectors differ between hosts) |
|
||||
| `BRAIN_API_BASE` (**no** `/v1`; the client appends it), `BRAIN_MODEL`, `BRAIN_MAX_TOKENS`, `BRAIN_STEPS` | the simulator's LLM opponents |
|
||||
| `DECISION_URL`, `DECISION_MODEL`, `DECISION_MODE` (`off`/`shadow`/`assist`), `DECISION_MIN_CONF`, `DECISION_TIMEOUT` | the decision model above |
|
||||
| `ollama` Python package | required by the card-search index build (ChromaDB's Ollama embedder imports it) |
|
||||
|
||||
## Tests
|
||||
`tests/farmhouse/` (standard library only, no network): `python -I -X utf8 tests/farmhouse/test_farmhouse_deck_links.py`, `..._sim.py`, `..._fit.py`.
|
||||
Run them with `-I` and from outside the package folders; do not put `services/` on `sys.path` (`services/logging.py` shadows the standard library).
|
||||
|
||||
## Things still worth doing
|
||||
- **Make the LLM-opponent turns faster** (a piloted move averages ~7.5 s: about 2.2k tokens in, ~30 out, so reading the prompt dominates).
|
||||
These are ideas, not measured; none is implemented:
|
||||
- *Shared prefix:* about 1.2k tokens of every prompt (the system prompt) are identical each time; a llama.cpp server can reuse them
|
||||
if the calls share the prefix, so only the board state is read each move.
|
||||
- *Smaller prompt:* send a compact board state instead of the indented JSON the engine builds now (`json.dumps(snapshot, indent=2)`
|
||||
in `deepseek_brain.py`).
|
||||
- *Parallel games:* run several games at once, which would also fill the GPU while one game waits on a move. Caveat: for long prompts
|
||||
two requests at once gave no extra throughput on a V100 (reading the prompt is compute-bound), so measure before relying on it.
|
||||
- Opponents are the engine's heuristics; only the piloted seat uses the model.
|
||||
- Build the card-search index and measure it.
|
||||
- The upstream `LICENSE` is missing (`pyproject.toml` says MIT). Ask the author to add one before publishing a derivative widely.
|
||||
- Forge-based features (the Java simulator, training) remain unavailable by design.
|
||||
@@ -1,212 +1,67 @@
|
||||
# Commander AI Lab
|
||||
|
||||
A full-stack Magic: The Gathering Commander research platform. Run thousands of simulated games, train neural networks on the outcomes, build decks with AI that learns from simulation data, get LLM-powered coaching, and play live 4-player battles — all from a single unified system.
|
||||
A full-stack MTG Commander AI research platform. Simulate hundreds of games overnight, train ML models on the results, and let simulation data automatically shape the AI deck builder — all from a web UI or CLI.
|
||||
|
||||
---
|
||||
## What It Does
|
||||
|
||||
## Overview
|
||||
|
||||
Commander AI Lab connects a rules-complete Forge engine, a fast Monte Carlo simulator, an LLM deck builder, an ML training pipeline, and multiple game clients (web, LAN, Unity) into one closed-loop platform. Simulation results feed back into deck building and AI decision-making, so the system improves the more it plays.
|
||||
|
||||
### Key Capabilities
|
||||
|
||||
- **Batch Simulation** — Run hundreds or thousands of Commander games unattended using Forge (rules-complete) or a headless Python Monte Carlo engine
|
||||
- **ML Training Pipeline** — Reinforcement learning (PPO) trains a neural net on game decision logs; outcome-weighted scoring nudges card weights automatically
|
||||
- **AI Deck Builder** — 7-step LLM pipeline (EDHrec → Scryfall → Ollama) with simulation-learned insights injected into prompts
|
||||
- **LLM Coach** — Streaming coaching sessions powered by Anthropic Claude or local Ollama models, enriched with RAG-retrieved card knowledge
|
||||
- **RAG Card Knowledge** — ChromaDB vector store over 30,000+ Scryfall cards with semantic search and auto-staleness monitoring
|
||||
- **LAN Battlefield** — Browser-based 4-player Commander game client with ML-powered AI opponents and precon deck support
|
||||
- **Unity Client** — Full Unity application with battleground scene, lobby system, WebSocket game state sync, and cross-platform builds
|
||||
- **Card Scanner** — OCR pipeline for physical card recognition via Ximilar API
|
||||
- **163 Precon Decks** — Complete preconstructed deck library in `.dck` format
|
||||
|
||||
---
|
||||
| Layer | What it is |
|
||||
|---|---|
|
||||
| **Monte Carlo Engine** | Pure-Python headless sim — runs thousands of games in minutes, no JVM needed |
|
||||
| **Forge Engine** | Java/Forge rules-complete sim for ground-truth validation |
|
||||
| **Overnight Runner** | Unattended batch runner — runs sims, updates sim weights, trains neural net |
|
||||
| **ML Pipeline** | Outcome-weighted RL nudges card scoring weights; neural net trains on decision logs |
|
||||
| **Deck Builder** | 7-step LLM pipeline (EDHrec → Scryfall → Ollama) now injected with sim-learned insights |
|
||||
| **AI Coach** | LLM-powered coaching sessions (Anthropic/Ollama) with streaming support |
|
||||
| **Web UI** | React + FastAPI dashboard for decks, sims, training, and deck building |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Clients │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────────┐ │
|
||||
│ │ Web UI │ │ LAN Client │ │ Unity Client │ │
|
||||
│ │ (React/TS) │ │ (HTML/JS) │ │ (C# / URP) │ │
|
||||
│ │ /ui │ │ /lan │ │ /unity-client │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────────────────────┘ │
|
||||
│ └────────────────┼────────────────┘ │
|
||||
│ │ HTTP/REST + WebSocket │
|
||||
│ ┌───────────────────────┴─────────────────────────────────────────┐│
|
||||
│ │ FastAPI Backend (lab_api.py — port 8080) ││
|
||||
│ │ ││
|
||||
│ │ /api/lab/* Batch simulation (Forge + Monte Carlo) ││
|
||||
│ │ /api/deck-builder/* 7-step LLM deck construction ││
|
||||
│ │ /api/coach/* Streaming LLM coaching sessions ││
|
||||
│ │ /api/ml/* Neural net training + policy server ││
|
||||
│ │ /api/rag/* ChromaDB vector search ││
|
||||
│ │ /api/game/* Live game sessions (Forge IPC) ││
|
||||
│ │ /api/policy/* ML policy decisions for AI players ││
|
||||
│ │ /ws/game/{id} WebSocket game state streaming ││
|
||||
│ └───────┬──────────────┬──────────────┬──────────────┬────────────┘│
|
||||
│ │ │ │ │ │
|
||||
│ ┌──────┴──────┐ ┌────┴─────┐ ┌──────┴──────┐ ┌────┴──────┐ │
|
||||
│ │ Forge │ │ Monte │ │ Ollama │ │ ChromaDB │ │
|
||||
│ │ Engine │ │ Carlo │ │ (local LLM) │ │ (vector │ │
|
||||
│ │ (Java/JVM) │ │ Engine │ │ qwen2.5:7b │ │ store) │ │
|
||||
│ └─────────────┘ │ (Python) │ └─────────────┘ └───────────┘ │
|
||||
│ └──────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐│
|
||||
│ │ ML Pipeline ││
|
||||
│ │ decisions_*.jsonl → update_weights.py → learned_weights.json ││
|
||||
│ │ → /api/ml/train → neural net checkpoints ││
|
||||
│ └──────────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Web UI (React/TypeScript — frontend/) │
|
||||
│ Deck Builder · Sim Dashboard · Training · Coach │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│ HTTP/REST + WebSocket
|
||||
┌────────────────────────┴────────────────────────────────────────┐
|
||||
│ FastAPI Server (lab_api.py / routes/ — port 8080) │
|
||||
│ /api/lab/* /api/ml/* /api/coach/* /api/deck-builder/* │
|
||||
└────────┬──────────────────┬──────────────────┬──────────────────┘
|
||||
│ │ │
|
||||
Java subprocess Python sim engine Ollama (local LLM)
|
||||
(Forge rules) (Monte Carlo) qwen2.5:7b + others
|
||||
│ │
|
||||
┌────────┴───────┐ ┌───────┴────────────────────────────────────┐
|
||||
│ Forge Engine │ │ src/commander_ai_lab/ │
|
||||
│ (rules- │ │ sim/ — engine, models, rules │
|
||||
│ complete) │ │ deck_builder/ — 7-step LLM pipeline │
|
||||
└────────────────┘ │ coach/ — streaming LLM coach │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
## Quick Start (Windows)
|
||||
|
||||
## Features
|
||||
|
||||
### Simulation Engines
|
||||
|
||||
| Engine | Description | Speed |
|
||||
|---|---|---|
|
||||
| **Forge** | Rules-complete Java engine — handles the stack, instants, triggered abilities, and all Commander rules. Each game spawns a JVM subprocess. | ~0.04 sims/sec (single-threaded) |
|
||||
| **Monte Carlo** | Headless Python engine — models combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped state, commander zone, multiplayer targeting, and board wipes. No JVM required. | Thousands of games in minutes |
|
||||
|
||||
Both engines feed structured results into the same ML pipeline.
|
||||
|
||||
### Deck Builder
|
||||
|
||||
The `POST /api/deck-builder/build` endpoint runs a 7-step pipeline:
|
||||
|
||||
1. Resolve commander via Scryfall
|
||||
2. Fetch EDHrec recommendations
|
||||
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
|
||||
4. Ollama suggests synergy/protection/wincon cards — **simulation insights injected here**
|
||||
5. Filter by color identity, collection, and ban list
|
||||
6. Ollama enforces ratios (trim/expand to 99) — **simulation insights injected here**
|
||||
7. Assemble final deck with smart substitution (guarantees 99-card output)
|
||||
|
||||
### AI Coach
|
||||
|
||||
LLM-powered coaching with two modes:
|
||||
|
||||
- **Full Session** — Deep analysis including removal coverage, ramp quality, draw engine profiling, win conditions, anti-synergy detection, pod meta evaluation, and tempo assessment
|
||||
- **Quick Digest** — Fast ~5-second summary for rapid feedback
|
||||
|
||||
Coaching prompts are enriched with semantically relevant cards from the ChromaDB RAG store (15 cards for full sessions, 8 for quick digests). Supports both Anthropic Claude and local Ollama models with streaming responses.
|
||||
|
||||
### RAG Card Knowledge
|
||||
|
||||
- **SQLite FTS5** bulk card database from Scryfall data
|
||||
- **ChromaDB** vector index with natural-language card embeddings
|
||||
- Automatic staleness monitoring (checks every 6 hours, auto-rebuilds if >14 days old)
|
||||
- Graceful fallback — RAG failures never block coaching or deck building
|
||||
|
||||
### ML Training Pipeline
|
||||
|
||||
```
|
||||
Overnight sims → decisions_*.jsonl
|
||||
↓
|
||||
update_weights.py → learned_weights.json (feeds deck builder)
|
||||
↓
|
||||
/api/ml/train → neural net training on decision dataset
|
||||
↓
|
||||
/api/policy/decide → ML policy serves AI decisions in live games
|
||||
```
|
||||
|
||||
- **Outcome-weighted RL** nudges card scoring weights from game results
|
||||
- **Neural net** trains on the full decision log dataset
|
||||
- **Distillation loop** — self-play → collect data → retrain → repeat
|
||||
- **ELO tracking** and model tournaments for comparing AI versions
|
||||
- Weights clamped to `[-20.0, 20.0]`, stored in `learned_weights.json`
|
||||
|
||||
### Win Condition Tracking
|
||||
|
||||
Games are classified by win type — not just "Combat" vs "Other":
|
||||
|
||||
- **Combat** — standard creature damage wins
|
||||
- **Combo** — Approach of the Second Sun, Felidar Sovereign, Happily Ever After, and 30+ known alternate-win cards
|
||||
- **Drain** — life drain effects
|
||||
- **Mill** — library depletion
|
||||
- **Poison** — infect/poison counter wins
|
||||
|
||||
Draw games (timeout) are analyzed separately with per-player life-at-timeout snapshots and likely cause identification.
|
||||
|
||||
### LAN Battlefield Client
|
||||
|
||||
Browser-based 4-player Commander game at `/lan`:
|
||||
|
||||
- **AI Bridge** — REST + WebSocket connectivity with ML policy decisions as Priority 1 (falls back to local heuristic AI)
|
||||
- **Deck System** — 3-tab selector: Precons (163 decks, grouped by set), EDHREC search, and custom deck import
|
||||
- **Player Panels** — Life counters, commander damage bars, poison/energy/experience/rad counters, emblem management
|
||||
- **Backend Detection** — Auto-detects the Commander AI Lab backend; works offline with local precon data
|
||||
|
||||
### Unity Client
|
||||
|
||||
Full Unity application (URP, .NET Standard 2.1) with:
|
||||
|
||||
- **Battleground Scene** — 4-seat layout with rotated perspectives, life/poison/commander damage widgets, phase tracker, turn order bar
|
||||
- **Card System** — Hand, battlefield, graveyard, exile zones with card art, tapped rotation, face-down state, summoning sickness indicators
|
||||
- **Stack & Mana** — LIFO stack display and 6-pip mana pool
|
||||
- **Lobby System** — Configure 4 seats (human/AI), POST to backend, WebSocket game state sync
|
||||
- **Elimination** — Grayscale shader overlay on eliminated players
|
||||
- **Token System** — Create, tap, add counters, delete tokens
|
||||
- **Cross-Platform** — Windows, WebGL, Android, iOS build targets
|
||||
- **Scenes** — MainMenu, Collection, DeckBuilder, DeckGenerator, Simulator, Coach, Scanner, Training, Precon, Battleground
|
||||
|
||||
### Card Scanner
|
||||
|
||||
OCR pipeline for physical Magic cards using the Ximilar API. Scan cards from photos and match them to the Scryfall database.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Tool | Version |
|
||||
|---|---|
|
||||
| Python | 3.10+ |
|
||||
| Java | 17+ |
|
||||
| Maven | 3.8+ |
|
||||
| Node.js | 18+ (for LAN client scripts) |
|
||||
| Ollama | Latest (for local LLM inference) |
|
||||
| [Forge](https://github.com/KoalaTrapLord/Forge-Repo) | Built at `D:\ForgeCommander\Forge-Repo` |
|
||||
|
||||
### Setup (Windows)
|
||||
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
|
||||
|
||||
```powershell
|
||||
# 1. Build Forge (one-time)
|
||||
cd D:\ForgeCommander\Forge-Repo
|
||||
cd D:\ForgeCommander\forge-repo
|
||||
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
|
||||
# 2. Build Commander AI Lab
|
||||
cd D:\ForgeCommander\commander-ai-lab
|
||||
mvn package "-DskipTests"
|
||||
|
||||
# 3. Install Python dependencies
|
||||
pip install -e ".[rag,dev]"
|
||||
# 3. Install Python deps
|
||||
pip install -e .
|
||||
|
||||
# 4. Pull an Ollama model for the deck builder
|
||||
ollama pull qwen2.5:7b
|
||||
|
||||
# 5. Launch
|
||||
# 4. Launch
|
||||
.\start-lab.bat
|
||||
# → Web UI: http://localhost:8080
|
||||
# → LAN Client: http://localhost:8080/lan/
|
||||
# → open http://localhost:8080
|
||||
```
|
||||
|
||||
Or use the automated installer:
|
||||
|
||||
```powershell
|
||||
.\install-commander-ai-lab.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overnight AFK Runner
|
||||
|
||||
Run batch simulations unattended, then automatically update card weights and train the neural net:
|
||||
Runs batch sims unattended, then updates card weights and trains the neural net:
|
||||
|
||||
```powershell
|
||||
python overnight-run.py # default: 11.5h DeepSeek sims
|
||||
@@ -219,9 +74,9 @@ python overnight-run.py --no-train # skip neural net training
|
||||
1. `update_weights.py` — nudges `learned_weights.json` from decision logs (feeds deck builder)
|
||||
2. `/api/ml/train` — trains the neural net on the full decision dataset
|
||||
|
||||
---
|
||||
## Sim → Deck Builder Feedback Loop
|
||||
|
||||
## Simulation → Deck Builder Feedback Loop
|
||||
Simulation outcomes automatically improve deck recommendations:
|
||||
|
||||
```
|
||||
Overnight sims → decisions_*.jsonl
|
||||
@@ -235,63 +90,24 @@ Ollama prompts (suggest_cards + enforce_deck_ratios)
|
||||
Better card recommendations
|
||||
```
|
||||
|
||||
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. The loop is a no-op until real sim data exists.
|
||||
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. No-op until real sim data exists.
|
||||
|
||||
---
|
||||
## Deck Builder Pipeline
|
||||
|
||||
## API Reference
|
||||
The `POST /api/deck-builder/build` endpoint runs 7 steps:
|
||||
|
||||
### Simulation
|
||||
1. Resolve commander via Scryfall
|
||||
2. Fetch EDHrec recommendations
|
||||
3. Fetch Scryfall candidates by category (ramp, removal, draw, lands)
|
||||
4. Ollama suggests synergy/protection/wincon cards ← **sim insights injected here**
|
||||
5. Filter by color identity, collection, ban list
|
||||
6. Ollama enforces ratios (trim/expand to 99) ← **sim insights injected here**
|
||||
7. Assemble final deck
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/lab/decks` | GET | List available Commander decks |
|
||||
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
|
||||
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
|
||||
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
|
||||
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
|
||||
| `/api/lab/history` | GET | List past batch runs |
|
||||
| `/api/lab/precons/deck` | GET | Fetch precon `.dck` file content |
|
||||
|
||||
### Deck Builder & Coach
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
|
||||
| `/api/coach/chat` | POST | Full coaching session (streaming) |
|
||||
| `/api/coach/quick-digest` | POST | Fast ~5s coaching summary |
|
||||
|
||||
### ML & Policy
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/ml/train` | POST | Start neural net training |
|
||||
| `/api/ml/train/status` | GET | Poll training progress |
|
||||
| `/api/policy/decide` | POST | Get ML policy decision for game state |
|
||||
|
||||
### RAG
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/rag/build` | POST | Build/rebuild ChromaDB index |
|
||||
| `/api/rag/search` | POST | Semantic card search |
|
||||
| `/api/rag/status` | GET | Index health and staleness |
|
||||
|
||||
### Live Game
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/game/start` | POST | Create a live game session |
|
||||
| `/api/game/action` | POST | Submit a player action |
|
||||
| `/api/game/{id}/actions` | GET | Poll Forge IPC actions |
|
||||
| `/ws/game/{game_id}` | WS | Real-time game state streaming |
|
||||
|
||||
---
|
||||
|
||||
## ML Scripts
|
||||
## ML Pipeline
|
||||
|
||||
```powershell
|
||||
# Update card weights from decision logs
|
||||
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
|
||||
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
|
||||
|
||||
# Reset weights to defaults
|
||||
@@ -301,7 +117,7 @@ python -m ml.scripts.update_weights --reset
|
||||
python ml/scripts/generate_synthetic_data.py
|
||||
```
|
||||
|
||||
---
|
||||
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
|
||||
|
||||
## Python Simulator API
|
||||
|
||||
@@ -313,122 +129,52 @@ stats = run_simulation(deck, num_games=100, name_a="My Deck")
|
||||
print(f"Win rate: {stats['win_rate']:.1%}, Avg turns: {stats['avg_turns']:.1f}")
|
||||
```
|
||||
|
||||
---
|
||||
The Monte Carlo engine models: combat keywords (flying, trample, deathtouch, lifelink, menace, reach, haste), summoning sickness, tapped/untap state, commander zone, multiplayer attack targeting, and board wipes. It does **not** model the stack, instants, or continuous type-changing effects.
|
||||
|
||||
## API Reference
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/lab/decks` | GET | List available Commander decks |
|
||||
| `/api/lab/start` | POST | Start Forge batch (returns batchId) |
|
||||
| `/api/lab/start-deepseek` | POST | Start Python/DeepSeek batch |
|
||||
| `/api/lab/status?batchId=X` | GET | Poll batch progress |
|
||||
| `/api/lab/result?batchId=X` | GET | Get completed batch JSON |
|
||||
| `/api/lab/history` | GET | List past batch runs |
|
||||
| `/api/ml/train` | POST | Start neural net training |
|
||||
| `/api/ml/train/status` | GET | Poll training progress |
|
||||
| `/api/coach/chat` | POST | LLM coaching session (streaming) |
|
||||
| `/api/deck-builder/build` | POST | Build a 99-card Commander deck |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
commander-ai-lab/
|
||||
├── lab_api.py # FastAPI server entry point
|
||||
├── overnight-run.py # AFK batch runner
|
||||
├── start-lab.bat # Windows one-click launcher
|
||||
├── install-commander-ai-lab.ps1 # Automated setup script
|
||||
├── pom.xml # Maven build (Java/Forge components)
|
||||
├── pyproject.toml # Python package config
|
||||
│
|
||||
├── routes/ # FastAPI route modules
|
||||
│ ├── lab.py # Batch simulation
|
||||
│ ├── deckbuilder.py # Deck builder pipeline
|
||||
│ ├── coach.py # LLM coaching
|
||||
│ ├── ml.py # ML training
|
||||
│ ├── rag.py # ChromaDB vector search
|
||||
│ ├── game.py # Live game sessions
|
||||
│ ├── policy.py # ML policy server
|
||||
│ ├── precon.py # Precon deck serving
|
||||
│ ├── scanner.py # Card OCR
|
||||
│ └── ws_game.py # WebSocket game state
|
||||
│
|
||||
├── services/ # Core service layer
|
||||
│ ├── forge_runner.py # Forge subprocess management
|
||||
│ ├── draw_game_analyzer.py # Draw/timeout analysis
|
||||
│ ├── rag_store.py # ChromaDB vector store
|
||||
│ └── card_text.py # Shared card text builder
|
||||
│
|
||||
├── lab_api.py # FastAPI server entry point
|
||||
├── frontend/ # React + TypeScript web UI
|
||||
├── ui/ # Legacy static HTML UI
|
||||
├── src/commander_ai_lab/
|
||||
│ ├── sim/ # Monte Carlo engine
|
||||
│ │ ├── engine.py # Game loop
|
||||
│ │ ├── models.py # GameResult, Card, Player
|
||||
│ │ ├── rules.py # Combat, keywords, commander rules
|
||||
│ │ ├── win_condition_parser.py # Alternate win detection
|
||||
│ │ └── learned_weights.json # ML-learned card weights
|
||||
│ ├── deck_builder/ # 7-step LLM pipeline
|
||||
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
|
||||
│ │ ├── core/ # Models, rules engine, assembler
|
||||
│ │ └── pipeline/ # build_deck.py orchestrator
|
||||
│ └── coach/ # LLM coaching
|
||||
│ ├── coach_service.py # Session + quick digest
|
||||
│ ├── prompt_template.py # System/user prompts
|
||||
│ └── embeddings.py # Card embedding utilities
|
||||
│
|
||||
├── ml/ # ML training pipeline
|
||||
│ ├── scripts/ # update_weights.py, ml_cli.py
|
||||
│ ├── training/ # Neural net trainer
|
||||
│ ├── encoder/ # Feature encoding
|
||||
│ └── models/ # Saved checkpoints
|
||||
│
|
||||
├── lan-client/ # Browser-based LAN battlefield
|
||||
│ ├── index.html # Entry point
|
||||
│ ├── engine.js # Game engine + ML AI turns
|
||||
│ ├── ai-bridge.js # Backend connector + policy bridge
|
||||
│ ├── player-panels.js # Enhanced player panels
|
||||
│ ├── ui.js # 3-tab deck selector + setup
|
||||
│ └── precons.js # 163 precon deck data
|
||||
│
|
||||
├── unity-client/ # Unity game client
|
||||
│ └── Assets/
|
||||
│ ├── Scenes/ # 11 scenes (MainMenu → Battleground)
|
||||
│ ├── Scripts/ # C# controllers, models, services
|
||||
│ ├── Prefabs/ # Card, deck slot, chat bubble
|
||||
│ └── Materials/ # URP materials + grayscale shader
|
||||
│
|
||||
├── scanner/ # Card OCR pipeline (Ximilar)
|
||||
├── precon-decks/ # 163 preconstructed decks (.dck)
|
||||
├── sample-decks/ # Sample deck files
|
||||
├── data/ # Runtime data (ChromaDB, bulk DB)
|
||||
├── logs/ # Simulation + decision logs
|
||||
├── docs/ # Architecture docs + schemas
|
||||
├── tests/ # Test suite
|
||||
├── scripts/ # Utility scripts
|
||||
├── .github/workflows/ # CI + Unity build automation
|
||||
└── src/main/java/ # Java/Forge batch runner
|
||||
└── commanderailab/
|
||||
│ ├── sim/ # Monte Carlo engine (engine, models, rules)
|
||||
│ ├── deck_builder/ # LLM deck builder pipeline
|
||||
│ │ ├── api/ # Scryfall, EDHrec, Ollama, sim_insights
|
||||
│ │ ├── core/ # Models, rules engine, assembler
|
||||
│ │ └── pipeline/ # build_deck.py (7-step orchestrator)
|
||||
│ └── coach/ # LLM coaching (Anthropic + Ollama)
|
||||
├── ml/
|
||||
│ ├── scripts/ # update_weights.py, ml_cli.py
|
||||
│ ├── training/ # Neural net trainer
|
||||
│ ├── encoder/ # Feature encoding
|
||||
│ └── models/ # Saved model checkpoints
|
||||
├── scanner/ # Card OCR pipeline
|
||||
├── logs/decisions/ # ML decision logs (gitignored)
|
||||
└── src/main/java/commanderailab/ # Java/Forge batch runner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Python 3.10+, FastAPI, Uvicorn |
|
||||
| Simulation (rules-complete) | Java 17, Maven, Forge |
|
||||
| Simulation (fast) | Python Monte Carlo engine |
|
||||
| LLM Inference | Ollama (qwen2.5:7b), Anthropic Claude |
|
||||
| Vector Store | ChromaDB with all-MiniLM-L6-v2 embeddings |
|
||||
| Card Database | SQLite FTS5 (Scryfall bulk data) |
|
||||
| ML Training | PyTorch, PPO reinforcement learning |
|
||||
| Web Client | HTML/CSS/JS |
|
||||
| Unity Client | Unity 2022 LTS, URP, C# |
|
||||
| CI/CD | GitHub Actions |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Monte Carlo engine** — no stack, no instants, no continuous type-changing effects (animated lands don't attack)
|
||||
- **Forge engine** — each game spawns a new JVM (~2–3s overhead); ~0.04 sims/sec single-threaded
|
||||
- **Forge** — winner life total not captured in quiet mode (only reports losers)
|
||||
- **Deck builder** — Ollama suggestions depend on the local model quality; `qwen2.5:7b` works well
|
||||
- **LAN client** — EDHREC commander search is a stub (backend-powered, not yet fully wired)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Commander AI Lab is an active research project. Check the [open issues](https://github.com/KoalaTrapLord/commander-ai-lab/issues) for areas where contributions are welcome.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
See repository for license details.
|
||||
- **Monte Carlo engine** — no stack, no instants, no continuous type changes (animated lands don't attack)
|
||||
- **Forge engine** — each game spawns a new JVM (~2-3s overhead), ~0.04 sims/sec single-threaded
|
||||
- **Forge winner life total** not captured (quiet mode only reports losers)
|
||||
- **Deck builder** — Ollama suggestions are only as good as the local model; `qwen2.5:7b` works well
|
||||
@@ -1,257 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Smoke-tests the Commander AI Lab policy server from PowerShell.
|
||||
Checks: server reachable -> /api/policy/health -> /api/policy/decide.
|
||||
Prints a plain-English pass/fail for every step.
|
||||
|
||||
.USAGE
|
||||
# From repo root (server already running in another window):
|
||||
.\Test-PolicyServer.ps1
|
||||
|
||||
# Custom host/port:
|
||||
.\Test-PolicyServer.ps1 -BaseUrl http://localhost:9000
|
||||
|
||||
# Pretty-print the full decide response:
|
||||
.\Test-PolicyServer.ps1 -Verbose
|
||||
#>
|
||||
param(
|
||||
[string]$BaseUrl = "http://localhost:8080",
|
||||
[switch]$Verbose
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# ---- helpers ----------------------------------------------------------------
|
||||
|
||||
function Write-Pass { param([string]$msg) Write-Host " [PASS] $msg" -ForegroundColor Green }
|
||||
function Write-Fail { param([string]$msg) Write-Host " [FAIL] $msg" -ForegroundColor Red }
|
||||
function Write-Info { param([string]$msg) Write-Host " [INFO] $msg" -ForegroundColor Cyan }
|
||||
function Write-Warn { param([string]$msg) Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||
function Write-Step { param([string]$msg) Write-Host "`n-- $msg" -ForegroundColor White }
|
||||
|
||||
$allPassed = $true
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[string] $Name,
|
||||
[scriptblock] $Block
|
||||
)
|
||||
Write-Step $Name
|
||||
try { & $Block }
|
||||
catch {
|
||||
Write-Fail $_.Exception.Message
|
||||
$script:allPassed = $false
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Step 1: server reachable -----------------------------------------------
|
||||
|
||||
Invoke-Step "Step 1 -- Server reachable at $BaseUrl" {
|
||||
$r1 = $null
|
||||
try { $r1 = Invoke-WebRequest -Uri "$BaseUrl/docs" -UseBasicParsing -TimeoutSec 5 } catch {}
|
||||
if ($r1 -and $r1.StatusCode -eq 200) {
|
||||
Write-Pass "FastAPI server is up (GET /docs -> 200)"
|
||||
return
|
||||
}
|
||||
$r2 = $null
|
||||
try { $r2 = Invoke-WebRequest -Uri "$BaseUrl/" -UseBasicParsing -TimeoutSec 5 } catch {}
|
||||
if ($r2 -and $r2.StatusCode -lt 500) {
|
||||
Write-Pass "FastAPI server is up (GET / -> $($r2.StatusCode))"
|
||||
return
|
||||
}
|
||||
throw "Cannot reach $BaseUrl -- is lab_api.py / uvicorn running?"
|
||||
}
|
||||
|
||||
# ---- Step 2: /api/policy/health ---------------------------------------------
|
||||
|
||||
$modelLoaded = $false
|
||||
|
||||
Invoke-Step "Step 2 -- GET $BaseUrl/api/policy/health" {
|
||||
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/policy/health" -Method Get -TimeoutSec 10
|
||||
if ($Verbose) { $resp | ConvertTo-Json -Depth 5 | Write-Host }
|
||||
|
||||
if ($resp.ready -eq $true) {
|
||||
Write-Pass "Model is loaded and ready (ready=true)"
|
||||
$script:modelLoaded = $true
|
||||
} elseif ($resp.status -eq "degraded") {
|
||||
Write-Fail "Health returned status=degraded / ready=false"
|
||||
Write-Warn " -> Policy model is NOT loaded."
|
||||
Write-Warn " -> Start the server with --load-policy, or call POST /api/ml/load_model first."
|
||||
Write-Warn " -> Confirm models/ contains a valid .pt checkpoint."
|
||||
$script:allPassed = $false
|
||||
} else {
|
||||
Write-Warn "Unexpected health response -- dumping:"
|
||||
$resp | ConvertTo-Json -Depth 5 | Write-Host
|
||||
$script:allPassed = $false
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Step 3: /api/policy/decide (synthetic snapshot) ------------------------
|
||||
|
||||
Invoke-Step "Step 3 -- POST $BaseUrl/api/policy/decide (synthetic 4-player snapshot)" {
|
||||
|
||||
if (-not $script:modelLoaded) {
|
||||
Write-Warn "Skipping /decide -- model not loaded (Step 2 failed)."
|
||||
Write-Warn "Fix Step 2 first, then re-run."
|
||||
return
|
||||
}
|
||||
|
||||
$body = [ordered]@{
|
||||
game_id = "ps1-smoke-test"
|
||||
turn = 3
|
||||
phase = "MAIN1"
|
||||
active_player = 0
|
||||
playstyle = "midrange"
|
||||
temperature = 1.0
|
||||
greedy = $false
|
||||
players = @(
|
||||
[ordered]@{
|
||||
seat = 0
|
||||
name = "TestPlayer0"
|
||||
life = 40
|
||||
manaAvailable = 3
|
||||
landCount = 3
|
||||
creaturesOnField = 1
|
||||
totalPowerOnBoard = 2
|
||||
hand = @("Cultivate", "Solemn Simulacrum", "Sol Ring")
|
||||
battlefield = @("Forest", "Forest", "Forest", "Birds of Paradise")
|
||||
graveyard = @()
|
||||
commandZone = @("Atraxa, Praetors Voice")
|
||||
},
|
||||
[ordered]@{
|
||||
seat = 1
|
||||
name = "TestPlayer1"
|
||||
life = 38
|
||||
manaAvailable = 2
|
||||
landCount = 2
|
||||
creaturesOnField = 0
|
||||
totalPowerOnBoard = 0
|
||||
handCount = 4
|
||||
hand = @()
|
||||
battlefield = @("Island", "Swamp")
|
||||
graveyard = @()
|
||||
commandZone = @("Ur-Dragon")
|
||||
},
|
||||
[ordered]@{
|
||||
seat = 2
|
||||
name = "TestPlayer2"
|
||||
life = 35
|
||||
manaAvailable = 4
|
||||
landCount = 4
|
||||
creaturesOnField = 2
|
||||
totalPowerOnBoard = 5
|
||||
handCount = 3
|
||||
hand = @()
|
||||
battlefield = @("Mountain", "Mountain", "Mountain", "Mountain", "Goblin Guide", "Monastery Swiftspear")
|
||||
graveyard = @()
|
||||
commandZone = @("Krenko, Mob Boss")
|
||||
},
|
||||
[ordered]@{
|
||||
seat = 3
|
||||
name = "TestPlayer3"
|
||||
life = 40
|
||||
manaAvailable = 0
|
||||
landCount = 0
|
||||
creaturesOnField = 0
|
||||
totalPowerOnBoard = 0
|
||||
handCount = 7
|
||||
hand = @()
|
||||
battlefield = @()
|
||||
graveyard = @()
|
||||
commandZone = @("Prossh, Skyraider of Kher")
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json -Depth 10
|
||||
|
||||
$resp = Invoke-RestMethod `
|
||||
-Uri "$BaseUrl/api/policy/decide" `
|
||||
-Method Post `
|
||||
-Body $body `
|
||||
-ContentType "application/json" `
|
||||
-TimeoutSec 30
|
||||
|
||||
if ($Verbose) {
|
||||
Write-Host ""
|
||||
Write-Host " Full /decide response:" -ForegroundColor White
|
||||
$resp | ConvertTo-Json -Depth 6 | Write-Host
|
||||
}
|
||||
|
||||
$ok = $true
|
||||
if ([string]::IsNullOrEmpty($resp.action)) {
|
||||
Write-Fail "response.action is empty"
|
||||
$ok = $false
|
||||
}
|
||||
if ($null -eq $resp.confidence) {
|
||||
Write-Fail "response.confidence is missing"
|
||||
$ok = $false
|
||||
}
|
||||
if ($resp.PSObject.Properties["error"] -and -not [string]::IsNullOrEmpty($resp.error)) {
|
||||
Write-Fail "Server returned error: $($resp.error)"
|
||||
$ok = $false
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
$confPct = [math]::Round($resp.confidence * 100, 1)
|
||||
Write-Pass "Decision received -> action='$($resp.action)' conf=$($confPct)% latency=$($resp.inference_ms)ms src=$($resp.vector_source)"
|
||||
|
||||
if ($resp.probabilities) {
|
||||
$top3 = $resp.probabilities.PSObject.Properties |
|
||||
Sort-Object { $_.Value } -Descending |
|
||||
Select-Object -First 3
|
||||
Write-Info "Top-3 action probabilities:"
|
||||
foreach ($p in $top3) {
|
||||
$pct = [math]::Round($p.Value * 100, 1)
|
||||
Write-Info (" " + $p.Name.PadRight(22) + "$pct%")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$script:allPassed = $false
|
||||
}
|
||||
}
|
||||
|
||||
# ---- Step 4: log line guidance ----------------------------------------------
|
||||
|
||||
Invoke-Step "Step 4 -- Confirm [routes.policy.decide] log line in server console" {
|
||||
Write-Info "After Step 3 you should see in the uvicorn window:"
|
||||
Write-Info " [decide] turn=3 phase=main_1 -> cast_ramp (conf=0.810) | top5=[...] | src=encoder"
|
||||
Write-Info ""
|
||||
Write-Info "If that line is MISSING:"
|
||||
Write-Info " 1. Confirm uvicorn started with --log-level info (the default)"
|
||||
Write-Info " 2. Confirm lab_api.py calls register_policy_routes(app, policy_svc)"
|
||||
Write-Info " 3. Confirm routes/policy.py is included via routes/__init__.py"
|
||||
Write-Info " 4. Logger name is [routes.policy.decide] -- check no filter silences it"
|
||||
Write-Pass "Guidance printed"
|
||||
}
|
||||
|
||||
# ---- Step 5: why Forge ignores the policy server ----------------------------
|
||||
|
||||
Invoke-Step "Step 5 -- Forge IPC status (informational)" {
|
||||
Write-Warn "Forge does NOT call /api/policy/decide on its own."
|
||||
Write-Warn "routes/game.py POST /api/game/start has a TODO: wire Forge engine launch."
|
||||
Write-Warn ""
|
||||
Write-Warn "Until wired, /decide is only hit by:"
|
||||
Write-Warn " - This script"
|
||||
Write-Warn " - overnight-run.py (batch sim path)"
|
||||
Write-Warn " - Manual Invoke-RestMethod / curl calls"
|
||||
Write-Warn ""
|
||||
Write-Warn "Forge launches its built-in AI and plays lands because it has"
|
||||
Write-Warn "no HTTP client pointed at localhost:8080."
|
||||
Write-Warn ""
|
||||
Write-Warn "Next step: wire GameSession inside POST /api/game/start,"
|
||||
Write-Warn "or add a PolicyClient hook in Forge AiController.java."
|
||||
Write-Pass "Status explained"
|
||||
}
|
||||
|
||||
# ---- Summary ----------------------------------------------------------------
|
||||
|
||||
Write-Host ""
|
||||
if ($allPassed) {
|
||||
Write-Host "==========================================" -ForegroundColor Green
|
||||
Write-Host " ALL STEPS PASSED -- policy server live" -ForegroundColor Green
|
||||
Write-Host "==========================================" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "==========================================" -ForegroundColor Red
|
||||
Write-Host " ONE OR MORE STEPS FAILED -- see above" -ForegroundColor Red
|
||||
Write-Host "==========================================" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class CoachService:
|
||||
logger.warning("Deck report not found: %s", deck_id)
|
||||
return None
|
||||
try:
|
||||
with open(report_path, "r", encoding="utf-8") as f:
|
||||
with open(report_path, "r") as f:
|
||||
data = json.load(f)
|
||||
return DeckReport(**data)
|
||||
except Exception as e:
|
||||
@@ -95,7 +95,7 @@ class CoachService:
|
||||
"""Persist a coaching session to disk and SQLite."""
|
||||
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with open(path, "w") as f:
|
||||
f.write(session.model_dump_json(indent=2))
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
@@ -147,7 +147,7 @@ class CoachService:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
return CoachSession(**data)
|
||||
except Exception as e:
|
||||
@@ -168,7 +168,7 @@ class CoachService:
|
||||
sessions = []
|
||||
for f in sorted(COACH_SESSIONS_DIR.glob("*.json"), reverse=True):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
with open(f, "r") as fh:
|
||||
data = json.load(fh)
|
||||
if deck_id and data.get("deckId", "") != deck_id:
|
||||
continue
|
||||
@@ -549,7 +549,7 @@ class CoachService:
|
||||
status.llmModels = []
|
||||
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
|
||||
else:
|
||||
llm_status = self.llm.check_connection_sync() # FARMHOUSE_COACH_STATUS_V1 (was an un-awaited coroutine)
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
status.llmModel = llm_status.get("active_model")
|
||||
status.llmModels = llm_status.get("models", [])
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.paren
|
||||
|
||||
# ── Ollama (OpenAI-compatible API) ────────────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-4-20250514")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
|
||||
|
||||
+1
-20
@@ -74,7 +74,7 @@ class LLMClient:
|
||||
try:
|
||||
status = await self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = self.model if self.model in (status.get("models") or []) else status["active_model"] # FARMHOUSE_MODEL_PIN_V1
|
||||
self._resolved_model = status["active_model"]
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
return self._resolved_model
|
||||
except Exception:
|
||||
@@ -103,14 +103,6 @@ class LLMClient:
|
||||
}
|
||||
if force_json:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
# FARMHOUSE_LLM_KNOBS_V1 (see patch_app.py)
|
||||
import os as _fh_os
|
||||
_fh_effort = _fh_os.environ.get("LLM_REASONING", "").strip()
|
||||
if _fh_effort:
|
||||
body["reasoning_effort"] = _fh_effort
|
||||
_fh_cap = int(_fh_os.environ.get("LLM_MAX_TOKENS", "0") or 0)
|
||||
if _fh_cap:
|
||||
body["max_tokens"] = min(body["max_tokens"], _fh_cap)
|
||||
return body
|
||||
|
||||
# ── Response parsing ──────────────────────────────────────
|
||||
@@ -268,17 +260,6 @@ class LLMClient:
|
||||
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
def check_connection_sync(self) -> dict: # FARMHOUSE_LLM_SYNC_V1
|
||||
"""Blocking version of check_connection (upstream called it and never defined it)."""
|
||||
try:
|
||||
_r = httpx.get(f"{self.base_url}/models", timeout=5.0, headers={"Content-Type": "application/json"})
|
||||
_r.raise_for_status()
|
||||
_ids = [m.get("id", "unknown") for m in _r.json().get("data", [])]
|
||||
return {"connected": True, "models": _ids,
|
||||
"active_model": self.model if self.model in _ids else (_ids[0] if _ids else self.model)}
|
||||
except Exception as _e:
|
||||
return {"connected": False, "error": str(_e), "models": [], "active_model": None}
|
||||
|
||||
async def check_connection(self) -> dict:
|
||||
"""Check if Ollama is reachable. Returns status dict."""
|
||||
try:
|
||||
|
||||
@@ -546,21 +546,6 @@ def build_quick_system_prompt(report: DeckReport,
|
||||
|
||||
|
||||
def build_user_prompt(report: DeckReport,
|
||||
candidates: Dict[str, List[dict]] = None,
|
||||
rag_cards: List[dict] = None,
|
||||
rules_context: List[dict] = None) -> str: # FARMHOUSE_RULES_CONTEXT
|
||||
text = _farmhouse_build_user_prompt(report, candidates, rag_cards)
|
||||
rules = []
|
||||
for item in (rules_context or [])[:3]:
|
||||
body = (item.get('text') or item.get('document') or item.get('content')) if isinstance(item, dict) else str(item)
|
||||
if body:
|
||||
rules.append('- ' + str(body).strip()[:700])
|
||||
if rules:
|
||||
text += '\n\n## Relevant rules (for grounding; do not quote them back)\n' + '\n'.join(rules)
|
||||
return text
|
||||
|
||||
|
||||
def _farmhouse_build_user_prompt(report: DeckReport,
|
||||
candidates: Dict[str, List[dict]] = None,
|
||||
rag_cards: List[dict] = None) -> str:
|
||||
"""Build the user prompt with full deck report data.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Phase 2: Dual-Brain AI Decision Engine
|
||||
@@ -0,0 +1,5 @@
|
||||
# Phase 6 - decision_logger.py
|
||||
# Logs per-decision: game_id, turn, phase, player_id, personality,
|
||||
# brain_used, prompt, raw_response, parsed_action, fallback_used
|
||||
# Configurable via LOG_AI_DECISIONS env var
|
||||
# See issue #173
|
||||
@@ -0,0 +1,15 @@
|
||||
# Phase 2 - game_orchestrator.py *** CORE PRIORITY ***
|
||||
# GameAIOrchestrator: select_brain(), assign_personalities(), get_action()
|
||||
# Brains: DeepSeek-R1-14B (complex) + GPT-OSS-20B (tactical)
|
||||
# ChromaDB RAG for per-player card knowledge
|
||||
# See issue #169
|
||||
|
||||
PERSONALITY_POOL = [
|
||||
"aggressive -- attack early and often, prioritize players with lowest life totals",
|
||||
"control -- counter key spells, hold mana open, only act when clearly advantageous",
|
||||
"combo -- develop mana and card advantage quietly, avoid conflict until ready to win",
|
||||
"political -- make deals, attack whoever is ahead, never fully commit to one target",
|
||||
"pillowfort -- build defensive pieces, let others fight, win slowly through attrition",
|
||||
"stompy -- play the biggest threats as fast as possible, ignore table politics",
|
||||
"tempo -- disrupt opponents early, trade efficiently, stay ahead on board",
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
# Phase 0: Card image cache and data enrichment
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCache, ImageCacheResult
|
||||
from commander_ai_lab.cards.card_data_enricher import enrich_card, enrich_card_list, enrich_game_state
|
||||
|
||||
__all__ = [
|
||||
"get_image_cache",
|
||||
"ImageCache",
|
||||
"ImageCacheResult",
|
||||
"enrich_card",
|
||||
"enrich_card_list",
|
||||
"enrich_game_state",
|
||||
]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Phase 0 — card_data_enricher.py
|
||||
|
||||
Injects art_url and art_url_back into card data dicts/DTOs
|
||||
before they are broadcast to Unity via WebSocket.
|
||||
|
||||
Used by:
|
||||
- Phase 1 game_state_parser.py (enriches cards in GameStateDTO)
|
||||
- Phase 3 WebSocket handler (enriches full game state before broadcast)
|
||||
|
||||
Lookup chain per card:
|
||||
1. scryfall_id present -> ImageCache.lookup_by_id()
|
||||
2. scryfall_id absent -> ImageCache.lookup_by_name() (Forge card name)
|
||||
3. Both fail -> Scryfall CDN fallback URL
|
||||
4. CDN fallback absent -> empty string (Unity shows card back texture)
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCacheResult
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.cards.card_data_enricher")
|
||||
|
||||
# Scryfall CDN fallback when both local cache and DB are unavailable
|
||||
_CDN_FRONT = "https://cards.scryfall.io/normal/front/{d1}/{d2}/{sid}.jpg"
|
||||
_CDN_BACK = "https://cards.scryfall.io/normal/back/{d1}/{d2}/{sid}.jpg"
|
||||
|
||||
|
||||
def _cdn_fallback_urls(scryfall_id: str) -> tuple[str, str | None]:
|
||||
if not scryfall_id:
|
||||
return "", None
|
||||
d1 = scryfall_id[0]
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
front = _CDN_FRONT.format(d1=d1, d2=d2, sid=scryfall_id)
|
||||
back = _CDN_BACK.format(d1=d1, d2=d2, sid=scryfall_id)
|
||||
return front, back
|
||||
|
||||
|
||||
def enrich_card(card: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Inject art_url and art_url_back into a single card dict.
|
||||
Mutates in place AND returns the card for convenience.
|
||||
|
||||
Input card dict is expected to have at minimum:
|
||||
- 'name': str (card name from Forge)
|
||||
- 'scryfall_id': str (optional but preferred)
|
||||
|
||||
After enrichment the card will have:
|
||||
- 'art_url': str front face image URL
|
||||
- 'art_url_back': str|None back face image URL (DFCs only)
|
||||
- 'has_back': bool
|
||||
"""
|
||||
# Skip if already enriched
|
||||
if card.get("art_url"):
|
||||
return card
|
||||
|
||||
cache = get_image_cache()
|
||||
result: ImageCacheResult | None = None
|
||||
|
||||
scryfall_id = card.get("scryfall_id", "")
|
||||
if scryfall_id:
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
|
||||
if result is None:
|
||||
name = card.get("name", "")
|
||||
if name:
|
||||
result = cache.lookup_by_name(name)
|
||||
|
||||
if result is not None:
|
||||
card["art_url"] = result.front_url
|
||||
card["art_url_back"] = result.back_url
|
||||
card["has_back"] = result.has_back
|
||||
else:
|
||||
# Hard fallback: build CDN URL from scryfall_id if available
|
||||
front, back = _cdn_fallback_urls(scryfall_id)
|
||||
card["art_url"] = front
|
||||
card["art_url_back"] = back if back else None
|
||||
card["has_back"] = False
|
||||
if not front:
|
||||
log.warning("No art URL resolved for card: %s (id=%s)", card.get("name"), scryfall_id)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def enrich_card_list(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Enrich a list of card dicts. Mutates in place."""
|
||||
for card in cards:
|
||||
enrich_card(card)
|
||||
return cards
|
||||
|
||||
|
||||
def enrich_game_state(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Enrich all card lists in a full GameStateDTO dict.
|
||||
Walks all players and all zones: hand, battlefield, graveyard, exile, command_zone.
|
||||
Mutates in place AND returns state.
|
||||
"""
|
||||
zone_keys = ("hand", "battlefield", "graveyard", "exile", "command_zone")
|
||||
|
||||
for player in state.get("players", []):
|
||||
for zone in zone_keys:
|
||||
cards = player.get(zone)
|
||||
if isinstance(cards, list):
|
||||
enrich_card_list(cards)
|
||||
|
||||
# Also enrich the stack
|
||||
for stack_item in state.get("stack", []):
|
||||
card = stack_item.get("card")
|
||||
if isinstance(card, dict):
|
||||
enrich_card(card)
|
||||
|
||||
return state
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Phase 0 — image_cache.py
|
||||
|
||||
SQLite-backed card image lookup service.
|
||||
|
||||
Provides:
|
||||
ImageCache — sync lookup (used by download scripts, CLI tools)
|
||||
AsyncImageCache — async lookup (used by FastAPI request handlers)
|
||||
|
||||
Lookup priority:
|
||||
1. SQLite index by scryfall_id (exact, fast)
|
||||
2. SQLite index by card name (case-insensitive, Forge card name format)
|
||||
3. CDN fallback URL (scryfall.io) — no local file, but still a valid URL
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.cards.image_cache")
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DB_PATH = PROJECT_ROOT / "data" / "card_image_index.db"
|
||||
STATIC_BASE_URL = "/static/card-images"
|
||||
|
||||
# Scryfall CDN URL pattern for fallback
|
||||
# Format: https://cards.scryfall.io/normal/front/{d1}/{d2}/{scryfall_id}.jpg
|
||||
# where d1/d2 are first two hex chars of the UUID
|
||||
def _cdn_url(scryfall_id: str, face: str = "front") -> str:
|
||||
"""Build a Scryfall CDN URL for a card face. Used as fallback."""
|
||||
d1 = scryfall_id[0] if scryfall_id else "0"
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
return f"https://cards.scryfall.io/normal/{face}/{d1}/{d2}/{scryfall_id}.jpg"
|
||||
|
||||
|
||||
class ImageCacheResult:
|
||||
"""Result of a card image lookup."""
|
||||
__slots__ = ("scryfall_id", "name_raw", "front_url", "back_url", "has_back", "is_local")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scryfall_id: str,
|
||||
name_raw: str,
|
||||
front_url: str,
|
||||
back_url: Optional[str],
|
||||
has_back: bool,
|
||||
is_local: bool,
|
||||
):
|
||||
self.scryfall_id = scryfall_id
|
||||
self.name_raw = name_raw
|
||||
self.front_url = front_url
|
||||
self.back_url = back_url
|
||||
self.has_back = has_back
|
||||
self.is_local = is_local
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"scryfall_id": self.scryfall_id,
|
||||
"name": self.name_raw,
|
||||
"art_url": self.front_url,
|
||||
"art_url_back": self.back_url,
|
||||
"has_back": self.has_back,
|
||||
"is_local": self.is_local,
|
||||
}
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""Synchronous SQLite-backed card image cache."""
|
||||
|
||||
def __init__(self, db_path: Path = DB_PATH, base_url: str = STATIC_BASE_URL):
|
||||
self._db_path = db_path
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
if not self._db_path.exists():
|
||||
log.warning(
|
||||
"Image index DB not found at %s. Run scripts/build_image_index.py first.",
|
||||
self._db_path
|
||||
)
|
||||
return None
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
return self._conn
|
||||
|
||||
def _row_to_result(self, row: sqlite3.Row) -> ImageCacheResult:
|
||||
scryfall_id = row["scryfall_id"]
|
||||
front_path = row["front_path"]
|
||||
back_path = row["back_path"]
|
||||
has_back = bool(row["has_back"])
|
||||
|
||||
if front_path:
|
||||
front_url = f"{self._base_url}/{scryfall_id}_front.jpg"
|
||||
back_url = f"{self._base_url}/{scryfall_id}_back.jpg" if has_back else None
|
||||
is_local = True
|
||||
else:
|
||||
# CDN fallback
|
||||
front_url = _cdn_url(scryfall_id, "front")
|
||||
back_url = _cdn_url(scryfall_id, "back") if has_back else None
|
||||
is_local = False
|
||||
|
||||
return ImageCacheResult(
|
||||
scryfall_id=scryfall_id,
|
||||
name_raw=row["name_raw"],
|
||||
front_url=front_url,
|
||||
back_url=back_url,
|
||||
has_back=has_back,
|
||||
is_local=is_local,
|
||||
)
|
||||
|
||||
def lookup_by_id(self, scryfall_id: str) -> Optional[ImageCacheResult]:
|
||||
"""Look up card by Scryfall UUID."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return self._cdn_fallback(scryfall_id)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM card_images WHERE scryfall_id = ?", (scryfall_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return self._row_to_result(row)
|
||||
return self._cdn_fallback(scryfall_id)
|
||||
|
||||
def lookup_by_name(self, name: str) -> Optional[ImageCacheResult]:
|
||||
"""Look up card by name (case-insensitive). Returns most recent printing."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT * FROM card_images WHERE name = ? LIMIT 1",
|
||||
(name.lower().strip(),)
|
||||
).fetchone()
|
||||
if row:
|
||||
return self._row_to_result(row)
|
||||
return None
|
||||
|
||||
def _cdn_fallback(self, scryfall_id: str) -> ImageCacheResult:
|
||||
"""Return CDN URLs when local cache is unavailable."""
|
||||
return ImageCacheResult(
|
||||
scryfall_id=scryfall_id,
|
||||
name_raw="",
|
||||
front_url=_cdn_url(scryfall_id, "front"),
|
||||
back_url=None,
|
||||
has_back=False,
|
||||
is_local=False,
|
||||
)
|
||||
|
||||
def close(self):
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
|
||||
# Module-level singleton for use by FastAPI route handlers
|
||||
_cache: Optional[ImageCache] = None
|
||||
|
||||
|
||||
def get_image_cache() -> ImageCache:
|
||||
"""Get the module-level ImageCache singleton."""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = ImageCache()
|
||||
return _cache
|
||||
@@ -0,0 +1 @@
|
||||
# Phase 1: Forge subprocess bridge
|
||||
@@ -0,0 +1,3 @@
|
||||
# Phase 6 - game_result_logger.py
|
||||
# Captures winner, turn count, elimination order on Forge game end
|
||||
# See issue #173
|
||||
@@ -0,0 +1,3 @@
|
||||
# Phase 1 - game_state_parser.py
|
||||
# Converts Forge stdout text -> canonical GameStateDTO JSON
|
||||
# See issue #168
|
||||
@@ -0,0 +1,4 @@
|
||||
# Phase 1 - subprocess_manager.py
|
||||
# ForgeGameSession + ForgeSubprocessManager
|
||||
# Drives interactive Commander games via Forge stdin/stdout pipe
|
||||
# See issue #168
|
||||
@@ -0,0 +1 @@
|
||||
# Phase 6: ML Training Integration
|
||||
@@ -0,0 +1,4 @@
|
||||
# Phase 6 - training_data_builder.py
|
||||
# Converts decision_log + game_result -> (state, action, reward) tuples
|
||||
# Feeds existing PPO pipeline
|
||||
# See issue #173
|
||||
@@ -0,0 +1,2 @@
|
||||
# Runtime data directory
|
||||
# card_image_index.db, decision_logs/, game_results.jsonl, training_sets/
|
||||
@@ -1,67 +0,0 @@
|
||||
# Laya connections
|
||||
|
||||
Laya (and Kev: same `POST /v1/systemone` API) is a **decision model**: typed multiple-choice, yes/no and score questions about a text, answered in
|
||||
one forward pass, no generated text. This page is where it plugs into the strategic-intelligence work in this repository
|
||||
([INTELLIGENCE-HANDOFF.md](INTELLIGENCE-HANDOFF.md)), what is measured, and how to make it worth using. Written 2026-10-26.
|
||||
|
||||
**Short version.** Every connection below is built, tested offline and **off by default**. Zero-shot, Laya is a poor action chooser
|
||||
(below the always-guess baseline) but a reasonable card classifier (48% against 17% by chance); it is too slow on a CPU for Forge's
|
||||
priority path (8 s against a 1.5 s budget). The way forward is fine-tuning on the decisions these connections record.
|
||||
|
||||
## What connects where
|
||||
|
||||
| Connection | Code | Mode | Notes |
|
||||
|---|---|---|---|
|
||||
| Farmhouse macro choice: Laya picks among CSIE's candidate macros, reading the CSIE ranking and ITI objective in its text | `farmhouse_decide.py` (called from `deepseek_brain.py`) | `DECISION_MODE=shadow` records agreement with the LLM; `assist` uses a pick it is `DECISION_MIN_CONF` sure of | existing; only offered candidates are accepted |
|
||||
| **Forge concrete priority choice**: Laya picks among the same concrete candidate IDs Forge sent to `/api/policy/decide/concrete` | `farmhouse_laya.advise_concrete` / `attach_concrete`, called from `routes/policy.py` | `shadow`: adds `laya` to the response, selection unchanged. `assist`: may replace `selected_id`, never one Forge tagged `stops_immediate_win` / `wins_now`; the original is kept as `csie_selected_id` | candidate described by kind, card, cost, target, tags (never by its opaque id); Forge still executes only an ID it sent |
|
||||
| **Extra questions in the same call**: "how dangerous are the opponents' boards?" (none/low/moderate/high/lethal) and "is there a threat that must be answered?" | `farmhouse_laya._extra_questions` | whenever Laya is asked | one forward pass answers all questions, so they cost almost nothing; recorded as weak signals for the learned-threat work, **not used to decide** |
|
||||
| **Capture**: every call is appended to `data/decisions/laya-shadow-<date>.jsonl` (`laya-shadow-v1`) | `farmhouse_laya.capture` | shadow / assist | the text Laya read, the offered options, its answer and extras, and the other deciders' picks (`labels`: `llm`, `csie`, `selected`) |
|
||||
| **Fine-tuning data**: capture log -> `train.jsonl` / `valid.jsonl` / `manifest.json`, split by game, with the baselines to beat | `scripts/farmhouse_laya_dataset.py` | offline | the manifest reports commonest-action, CSIE-vs-teacher and Laya-zero-shot agreement on the validation side |
|
||||
| **Card role evidence**: removal, board wipe, ramp, card draw, counterspell, protection, tutor, creature, other, from Oracle text | `farmhouse_laya.classify_roles` (batched, cached, with `source: laya:<model>` provenance); `scripts/farmhouse_laya_roles_eval.py` measures it | on demand | evidence for ITI / Tagger-style use, never a rules fact |
|
||||
|
||||
Not built (still the open items in the handoff, none of which Laya can substitute for): compiling and running the Forge controller, the bounded
|
||||
combo executor, the multi-turn planner, the calibrated learned-threat model. Laya's "threat" and "must answer" extras are only raw material for that last one.
|
||||
|
||||
## Where the hooks are
|
||||
- `src/commander_ai_lab/sim/farmhouse_decide.py` (committed): the macro-choice client gained the extra questions, the state text/options in its
|
||||
answer, and `record()` writes the capture line.
|
||||
- `routes/policy.py` (**working tree only, uncommitted**: the route itself is part of the pending strategic-evaluator work, so the hook was
|
||||
left for that commit). It is one block in `decide_concrete`, after the result is computed:
|
||||
`if _L.mode() != "off": advice = await run_in_threadpool(_L.advise_concrete, ...); result = _L.attach_concrete(...)`, guarded by `except ImportError`.
|
||||
- Everything else is new files: `farmhouse_laya.py`, the two scripts, `tests/farmhouse/test_farmhouse_laya*.py`, `farmhouse/laya/`.
|
||||
|
||||
## Settings
|
||||
`DECISION_URL` (e.g. `http://laya.commander-lab.svc.cluster.local:8080`), `DECISION_MODE` (`off` default / `shadow` / `assist`), `DECISION_MODEL`
|
||||
(a checkpoint such as `typed-decisions`), `DECISION_MIN_CONF` (0.7), `DECISION_TIMEOUT` (5 s), `DECISION_TIMEOUT_FORGE` (**1.5 s**: Forge waits on this),
|
||||
`DECISION_API_KEY`, `LAYA_CAPTURE_DIR` (`data/decisions`), `LAYA_CAPTURE=0` to stop writing capture lines. Three failures in a row pause asking for a minute.
|
||||
|
||||
## Rules the code enforces
|
||||
1. Text is built from public facts and the acting player's own hand. An opponent's `hand` field is never read, even if a client sends one (tested).
|
||||
2. Laya's answer must be one of the options that were offered; anything else is discarded.
|
||||
3. In assist, a pick Forge marked `stops_immediate_win` / `wins_now` is never overridden, and the response says what CSIE chose.
|
||||
4. A missing, slow or broken server returns "no answer"; nothing raises and the game proceeds as without Laya.
|
||||
5. Scores and probabilities are relative signals; Laya's probabilities are not calibrated for this game.
|
||||
|
||||
## What was measured (2026-10-26, `typed-decisions` checkpoint, 4-thread CPU)
|
||||
- **Macro action vs the LLM, 149 recorded decisions:** 16.1% (English) and 18.6% (typed-decisions); always guessing the commonest action 27.5%.
|
||||
- **Forge-shaped priority choice, 3 scenarios, with CSIE's ranking in the text:** agreed with CSIE all three times (expected: the ranking is in the
|
||||
text it reads), 42-48% sure, **7.5-8.4 s per call** with the extra questions. That is five times Forge's 1.5 s budget: on this CPU the Forge path
|
||||
would time out and fall back; it needs a GPU (33 ms quoted) or a smaller text.
|
||||
- **Extras:** "threat" said *high* for a lethal stack, a calm board and a behind board alike (useless zero-shot); "must answer" gave 0.48 for the
|
||||
lethal stack against 0.25 and 0.29 for the others (right direction, weak).
|
||||
- **Card roles from Oracle text, 150 unambiguous cards:** 48.0% (16.7% by chance among the six roles present); counterspell 76%, board wipe 64%,
|
||||
removal 40%, tutor 40%, ramp 36%, card draw 32%; when 70% sure, 69% right but only 11% of cards reach that. **3.2 s per card** in batches on the CPU:
|
||||
fine for the cards in a hand (cached), not for tagging 30,000 cards (about a day).
|
||||
|
||||
## How to make it worth using
|
||||
1. Run games with `DECISION_URL` set and `DECISION_MODE=shadow` (Farmhouse LLM games, and Forge games through `/api/policy/decide/concrete`).
|
||||
2. `python scripts/farmhouse_laya_dataset.py --teacher llm` (or `csie`, or `selected`). Read `manifest.json`: the **validation baselines** are the bar.
|
||||
A student of CSIE can at best equal CSIE, which is free; a student of the LLM is the useful one. Several thousand rows are needed.
|
||||
3. Fine-tune with Laya's own notebook/script (github.com/NandhaKishorM/laya, `notebooks/`; 2xT4 about 4-5 hours, or Apple silicon). Its worked example
|
||||
(browser agent choosing among ~45 concrete candidates) went from 0.10 zero-shot to 0.66, so a concrete-candidate head is the shape that works.
|
||||
Adapt the row field names if its loader differs.
|
||||
4. Serve the result (`LAYA_EXTRA_MODELS` / `DECISION_MODEL`), rerun `scripts/farmhouse_decision_eval.py` and the dataset manifest, and only then
|
||||
try `assist`. A GPU host makes the Forge path possible.
|
||||
5. Keep game result and decision quality separate (handoff boundary 6 / learned-threat contract): a captured label is a teacher's choice, not proof it was right.
|
||||
|
||||
Deployment reference copies are in `farmhouse/laya/` (image, manifests, CI); the source of truth is the farmhouse-os GitOps repo.
|
||||
@@ -1,74 +0,0 @@
|
||||
# phase-rs and commander-playtester: assessment and plan
|
||||
|
||||
Researched 2026-10-26 from the public repositories and data files only. **Nothing from either project has been run, vendored or deployed.**
|
||||
Facts marked (verified) were read from the source; (unverified) are inferences to check before building on them.
|
||||
|
||||
## phase-rs/phase: a rules engine
|
||||
|
||||
A Rust rules engine (turns, priority, the stack, combat, layers, triggers, replacement effects) that compiles to native and WebAssembly, with a game
|
||||
client, a WebSocket server and its own AI. Dual licensed Apache-2.0 / MIT (`LICENSE-APACHE`, `LICENSE-MIT`; GitHub's licence field shows "none" only
|
||||
because there are two files). Card data from MTGJSON (MIT); card images come from Scryfall at runtime; the repo says it bundles no card art or
|
||||
Comprehensive Rules text and has a DMCA/fan-content policy. One maintainer, young (weeks to months), fast-moving (latest engine tag v0.100.0).
|
||||
|
||||
Why it matters here: our Python simulator has no rules stack (the handoff says so: `stack_supported: false`) and Forge needs a JDK and Maven that
|
||||
are not in the image. Phase is a rules-accurate engine that can run headless.
|
||||
|
||||
### Coverage of the decks in this app (verified, from phase's published `coverage-data.json`)
|
||||
Phase reports 88.9% of 36,037 cards implemented overall and 92.5% for Commander. For the five saved decks, distinct cards supported:
|
||||
Empress V8 72/77, Landfall 65/66, God and Anime 69/71, Im not dead yet 82/84, Pew Pew 88/93: **376 of 391 (96.2%)**. The 15 unsupported cards have
|
||||
one or two parsing gaps each (e.g. Gale Waterdeep Prodigy, Hall of Echoes, Precursor Golem, Invigorate, Mavinda, Drana and Linvala, Tibalt's Trickery).
|
||||
"Supported" means every ability parsed; it is not a guarantee that each card plays correctly. How the engine treats an unsupported card in a game
|
||||
(blank, no-op, refused) is **unverified**; test it before trusting a result that involves one.
|
||||
|
||||
### The parts that fit
|
||||
| Part | What it gives us | Notes |
|
||||
|---|---|---|
|
||||
| `ai-duel` (crate `phase-ai`) | headless AI-vs-AI games from the command line: seeded, deterministic "measurement mode", batches, JSON reports, a 4-player Commander suite, paired-seed comparison | decks are plain JSON `{"name", "main":[{"name","count"}]}` (verified). **No prebuilt binary is released**: it needs a Rust build (in CI or a container, not on the workstation). How a custom Commander deck and commander are supplied to `--commander-suite` is unverified (that suite reads a feed file) |
|
||||
| `phase-server` | a WebSocket game server (lobby, hidden information, spectators) | a prebuilt image is published (`ghcr.io/phase-rs/phase-server`, tags `latest`, `preview`, `sha-...`). A Python client could play seats and record every decision with real rules (protocol documentation not yet read) |
|
||||
| `phase-llm` | **model-driven AI seats**: the engine hands the model its legal option list, the model returns an index, the engine re-validates it, and any failure falls back to the heuristic AI | same principle as `farmhouse_laya.py`. Speaks OpenAI-style chat (so Olla / twin-turbo) **and the System One protocol Laya and Kev serve** (verified: `WireProtocol::SystemOneRelay`, question id `pick`, 2 to 255 options) |
|
||||
| `POST /jev/systemone` relay in `phase-server` | forwards the System One request to the URL in `TYPESAFE_API_URL` (verified, env var, `http` allowed for local setups) | point it at `http://laya...:8080/v1/systemone` to put Laya or Kev in a seat. The relay sends the key as `Authorization: Bearer` and does not follow redirects |
|
||||
|
||||
The attraction for the Laya work: a captured decision from phase is an **engine-issued option list plus the pick**, in rules-accurate games. That
|
||||
is a better fine-tuning source than this repository's approximate simulator, and the exporter (`scripts/farmhouse_laya_dataset.py`) could take it with a small adapter.
|
||||
|
||||
## RafaelAugustScherer/commander-playtester: a browser app on top of phase
|
||||
|
||||
TypeScript/React single-page app, no backend. Runs phase's engine as WebAssembly in a Web Worker. Deck library with Moxfield / Archidekt / plain-text
|
||||
import, a goldfishing Monte Carlo (mulligan rate, screw and flood, land drops, ramp by turn 3, curve), real engine matches (watch, or pilot one seat),
|
||||
and 1-50 match head-to-head runs with a win rate and 95% confidence interval. Pins the phase engine in `engine-manifest.json` (v0.100.0; its README
|
||||
still says v0.57.0) and fetches the WASM and card data with SHA-256 checks. Zero stars, last pushed 2026-10-24.
|
||||
|
||||
**It has no LICENSE file** (verified: the repository metadata shows none). Without a licence the code is all rights reserved: it can be read and used
|
||||
as a reference, but not copied into this repository or redistributed. Ask the author to add a licence (MIT would do) before reusing any of it. What is
|
||||
worth taking as ideas, not code: the goldfishing metrics, the head-to-head confidence-interval report, the name-only deck payload for the engine, and
|
||||
the engine-smoke gate that boots the real engine and plays a match to completion after every upgrade.
|
||||
|
||||
## Recommended plan, smallest useful step first
|
||||
1. **Prove it on one deck.** Build `ai-duel` from the pinned phase tag in CI (Rust, container), convert one saved deck to the JSON format, run a seeded
|
||||
batch against a baseline deck, and read the result. Answers: does it run a Commander game end to end, what does it do with an unsupported card, how
|
||||
long is a game. This replaces the guesswork about the Python simulator's win rates.
|
||||
2. **A `phase-sim` service** in the commander-lab namespace (no egress, called only by the app) with an endpoint "run N rules-accurate games for deck X
|
||||
against Y", returning win rate, confidence interval, wins by seat and the game log; the app's results panel reads it. The Python simulator stays for
|
||||
instant, rough Monte Carlo.
|
||||
3. **Model seats.** Run `phase-server` with `TYPESAFE_API_URL` at Laya (or at a fine-tuned one later) and the OpenAI-compatible seat at Olla; compare
|
||||
heuristic-only, LLM and Laya seats. Record every decision for the Laya dataset.
|
||||
4. **Only then** consider the playtester's UI ideas, after the author adds a licence or as a clean reimplementation.
|
||||
|
||||
## What was built from this plan (2026-10-27)
|
||||
1. **The headless runner** (`ai-commander`, Commander four-player, batch mode) is built from the v0.118.0 tag with the card database that release was published with, in a
|
||||
container (`farmhouse/phase/Dockerfile`). Not `ai-duel` for the decks: `ai-commander` takes a feed JSON of four decks, which is exactly what the app has.
|
||||
2. **The service and the app side**: `farmhouse/phase/phase_sim.py` (HTTP, jobs, parser, warnings) and `routes/farmhouse_phase.py` + `ui/phase-sim.html`. Games that stall, hit the
|
||||
action cap or panic are reported and never counted.
|
||||
3. **Model seats**: not a setting. phase's model support lives only in its browser client (`engine-wasm` calls `phase-llm`; the HTTP is done by TypeScript), and
|
||||
`phase-llm` depends on `phase-ai`, so the headless binary cannot use it. `farmhouse/phase/llm-seat/` is a new crate that includes the runner and installs a hook
|
||||
(two anchored edits to phase's source). The server-side Jev relay and the browser client were not needed. Details: `farmhouse/phase/README.md`.
|
||||
Not done: the playtester's UI (no licence), a Python client for phase's WebSocket server (a large, fast-moving protocol), feeding the captured model-seat decisions into
|
||||
`scripts/farmhouse_laya_dataset.py` (the natural next step: each is an engine-issued option list plus a pick).
|
||||
|
||||
## Risks and open questions
|
||||
- Phase is a one-person, fast-moving project: pin tags, keep a smoke test that plays a full game, expect breaking changes.
|
||||
- Rust builds are heavy; keep them in CI/containers. Resource and image size are unmeasured.
|
||||
- Card coverage is parse-level; correctness per card is unmeasured. Unsupported cards must be flagged in any result.
|
||||
- The phase server and its AI use CPU; its measured game speed is unknown (the README says a game is thousands of AI decisions).
|
||||
- Legal: phase is Apache/MIT with Wizards fan-content wording; the playtester has no licence. The card data is MTGJSON (MIT).
|
||||
- Not yet read: phase's WebSocket protocol, how `--commander-suite` accepts custom decks, and whether the server can run unattended self-play.
|
||||
@@ -1,785 +0,0 @@
|
||||
# Commander-AI-Lab — Unity TCGEngine Commander Board: Implementation Plan (v5)
|
||||
|
||||
> **Architecture Principle:** Forge = Rules Authority · FastAPI = Hub · TCGEngine Unity = Renderer · Dual-Brain AI = Decision Engine
|
||||
|
||||
---
|
||||
|
||||
## System Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ UNITY (TCGEngine — 2D Board) │
|
||||
│ PlayerArea x4: Hand | Library | Graveyard | Exile | Command │
|
||||
│ Battlefield zones per player · Card art from local cache │
|
||||
│ GameNetworkManager.cs ←── WebSocket ──► FastAPI │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FASTAPI (commander-ai-lab) │
|
||||
│ /ws/game — WebSocket relay │
|
||||
│ /game/action — receive player/AI action, forward to Forge │
|
||||
│ ForgeSubprocessManager — stdin/stdout pipe to Forge JVM │
|
||||
│ CardEnricher — injects art_url from local image cache │
|
||||
│ AIOrchestrator — routes decisions to DeepSeek-R1 or GPT-OSS │
|
||||
│ ChromaDB RAG — card knowledge scoped per player deck │
|
||||
│ ai_decisions.jsonl — logs every AI decision (PPO dataset) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FORGE (Java — Headless Rules Engine) │
|
||||
│ Full Commander rules: tax, 21-commander-damage, 40 life │
|
||||
│ 4-player turn queue · Zone redirects · State-based actions │
|
||||
│ Emits game state JSON to stdout · Receives actions via stdin │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dual-Brain AI Decision Flow
|
||||
|
||||
This is the **core feature** of Commander-AI-Lab. Every AI player decision runs through this pipeline:
|
||||
|
||||
```
|
||||
Forge emits GameStateDTO (JSON)
|
||||
└─► FastAPI detects active player is AI
|
||||
└─► select_brain(phase, stack_size)
|
||||
├─ DeepSeek-R1-14B → combat, stack decisions, removal
|
||||
└─ GPT-OSS-20B → main phase, ramp, card advantage
|
||||
└─► ChromaDB.query(player_deck_cards, top_k=5)
|
||||
└─► build_prompt(state, rag_context, personality)
|
||||
└─► LLM.generate(prompt)
|
||||
└─► parse_action_json(response)
|
||||
├─ validate vs legal_actions[]
|
||||
├─ log to ai_decisions.jsonl
|
||||
└─► Forge stdin ← chosen action
|
||||
```
|
||||
|
||||
### Brain Selector (`ai/brain_selector.py`)
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class Brain(Enum):
|
||||
DEEPSEEK = "deepseek-r1:14b"
|
||||
GPT_OSS = "gpt-oss-20b" # adjust to your Ollama model tag
|
||||
|
||||
def select_brain(phase: str, stack_size: int) -> Brain:
|
||||
"""
|
||||
DeepSeek-R1: combat math, instant-speed stack decisions, targeted removal.
|
||||
GPT-OSS: sorcery-speed strategy, ramp sequencing, card selection.
|
||||
"""
|
||||
combat_phases = {"declare_attackers", "declare_blockers", "combat_damage"}
|
||||
if phase in combat_phases or stack_size > 0:
|
||||
return Brain.DEEPSEEK
|
||||
return Brain.GPT_OSS
|
||||
```
|
||||
|
||||
### Prompt Builder (`ai/prompt_builder.py`)
|
||||
|
||||
```python
|
||||
from chromadb import Client
|
||||
from .personality import get_personality_prompt
|
||||
|
||||
chroma = Client()
|
||||
card_collection = chroma.get_collection("mtg_cards")
|
||||
|
||||
def build_prompt(state: dict, player_id: str, personality: str) -> str:
|
||||
# Pull card knowledge scoped to THIS player's deck
|
||||
player_cards = [c["name"] for c in state["players"][player_id]["library"]]
|
||||
rag_results = card_collection.query(
|
||||
query_texts=player_cards[:10],
|
||||
n_results=5
|
||||
)
|
||||
card_context = "\n".join(rag_results["documents"][0])
|
||||
|
||||
personality_prompt = get_personality_prompt(personality)
|
||||
|
||||
return f"""You are an AI playing Magic: The Gathering Commander.
|
||||
{personality_prompt}
|
||||
|
||||
## Current Game State
|
||||
- Phase: {state['phase']}
|
||||
- Active Player: {player_id}
|
||||
- Life Totals: {state['life_totals']}
|
||||
- Commander Damage Dealt: {state['commander_damage']}
|
||||
- Stack: {state['stack']}
|
||||
- Your Hand: {[c['name'] for c in state['players'][player_id]['hand']]}
|
||||
- Battlefield: {[c['name'] for c in state['players'][player_id]['battlefield']]}
|
||||
- Available Mana: {state['players'][player_id]['mana_pool']}
|
||||
|
||||
## Legal Actions
|
||||
{state['legal_actions']}
|
||||
|
||||
## Card Knowledge (RAG Context)
|
||||
{card_context}
|
||||
|
||||
Respond with a single JSON object:
|
||||
{{"action": "<action_type>", "card_id": "<id_or_null>", "targets": [], "reasoning": "<brief>"}}
|
||||
"""
|
||||
```
|
||||
|
||||
### Personality System (`ai/personality.py`)
|
||||
|
||||
```python
|
||||
import random
|
||||
|
||||
PERSONALITIES = {
|
||||
"aggro_beatdown": "You play aggressively. Attack every turn. Remove blockers. Win through combat damage as fast as possible.",
|
||||
"control_lockdown": "You play control. Counter key threats. Hold up mana for interaction. Win only when the board is locked.",
|
||||
"combo_enabler": "You dig for your combo pieces. Every decision is evaluated by how it advances your win condition.",
|
||||
"political_diplomat": "You form alliances. Attack the leader. Make deals. Never be the archenemy.",
|
||||
"voltron_commander": "Everything buffs your commander. Protect it at all costs. Win through commander damage.",
|
||||
"stax_prison": "You tax and restrict opponents. Deploy hate pieces. Win through attrition and resource denial.",
|
||||
"tempo_value": "You trade efficiently. Generate card advantage. Win by being ahead on resources.",
|
||||
"chaos_wildcard": "You make unpredictable decisions. Prioritize fun and chaos over optimal play.",
|
||||
}
|
||||
|
||||
def assign_personalities(player_ids: list[str], deck_archetypes: dict) -> dict[str, str]:
|
||||
"""
|
||||
Assign a personality per AI player at game creation.
|
||||
80% chance: archetype-matched personality.
|
||||
20% chance: random wildcard for unpredictability.
|
||||
"""
|
||||
assignments = {}
|
||||
for pid in player_ids:
|
||||
archetype = deck_archetypes.get(pid, "tempo_value")
|
||||
if random.random() < 0.20:
|
||||
assignments[pid] = random.choice(list(PERSONALITIES.keys()))
|
||||
else:
|
||||
assignments[pid] = archetype
|
||||
return assignments
|
||||
|
||||
def get_personality_prompt(personality: str) -> str:
|
||||
return f"## Your Playstyle\n{PERSONALITIES.get(personality, PERSONALITIES['tempo_value'])}"
|
||||
```
|
||||
|
||||
### AI Orchestrator (`ai/ai_orchestrator.py`)
|
||||
|
||||
```python
|
||||
import json, time, logging
|
||||
from pathlib import Path
|
||||
from .brain_selector import select_brain, Brain
|
||||
from .prompt_builder import build_prompt
|
||||
import ollama
|
||||
|
||||
LOG_PATH = Path("logs/ai_decisions.jsonl")
|
||||
LOG_PATH.parent.mkdir(exist_ok=True)
|
||||
|
||||
async def get_ai_action(state: dict, player_id: str, personality: str) -> dict:
|
||||
brain = select_brain(state["phase"], len(state["stack"]))
|
||||
prompt = build_prompt(state, player_id, personality)
|
||||
t_start = time.time()
|
||||
|
||||
try:
|
||||
response = ollama.chat(
|
||||
model=brain.value,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": 0.3, "num_predict": 256}
|
||||
)
|
||||
raw = response["message"]["content"]
|
||||
# Extract JSON from response (model may wrap in markdown)
|
||||
action = json.loads(raw[raw.find("{"):raw.rfind("}")+1])
|
||||
except Exception as e:
|
||||
logging.warning(f"AI parse failed ({brain.value}): {e}. Passing turn.")
|
||||
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "parse_error"}
|
||||
|
||||
# Validate against Forge legal actions
|
||||
if action.get("action") not in [a["type"] for a in state["legal_actions"]]:
|
||||
logging.warning(f"Illegal action returned by {brain.value}. Falling back to pass.")
|
||||
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "illegal_action"}
|
||||
|
||||
# Log for PPO training dataset
|
||||
log_entry = {
|
||||
"timestamp": time.time(),
|
||||
"player_id": player_id,
|
||||
"brain": brain.value,
|
||||
"personality": personality,
|
||||
"phase": state["phase"],
|
||||
"action": action,
|
||||
"reasoning": action.get("reasoning", ""),
|
||||
"latency_ms": round((time.time() - t_start) * 1000),
|
||||
}
|
||||
with open(LOG_PATH, "a") as f:
|
||||
f.write(json.dumps(log_entry) + "\n")
|
||||
|
||||
return action
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Local Card Image Cache
|
||||
|
||||
**Build this first.** Every downstream system depends on local art URLs.
|
||||
|
||||
### Step 1 — Bulk Download (`scripts/download_card_images.py`)
|
||||
|
||||
```python
|
||||
import requests, json, os
|
||||
from pathlib import Path
|
||||
from tqdm import tqdm
|
||||
|
||||
BULK_URL = "https://api.scryfall.com/bulk-data/default-cards"
|
||||
IMAGE_DIR = Path("static/card-images")
|
||||
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def download_images():
|
||||
# Get bulk data URL
|
||||
meta = requests.get(BULK_URL).json()
|
||||
cards = requests.get(meta["download_uri"], stream=True).json()
|
||||
|
||||
for card in tqdm(cards, desc="Downloading card images"):
|
||||
image_uris = card.get("image_uris") or {}
|
||||
url = image_uris.get("normal")
|
||||
if not url:
|
||||
continue # skip DFCs without top-level image_uris
|
||||
|
||||
safe_name = card["name"].replace("/", "_").replace(" ", "_")
|
||||
set_code = card.get("set", "unknown")
|
||||
filename = IMAGE_DIR / set_code / f"{safe_name}_{card['collector_number']}.jpg"
|
||||
filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if filename.exists():
|
||||
continue # already cached
|
||||
|
||||
img_data = requests.get(url, timeout=10).content
|
||||
filename.write_bytes(img_data)
|
||||
|
||||
if __name__ == "__main__":
|
||||
download_images()
|
||||
```
|
||||
|
||||
### Step 2 — Build SQLite Index (`scripts/build_image_index.py`)
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_DIR = Path("static/card-images")
|
||||
DB_PATH = Path("data/card_images.db")
|
||||
DB_PATH.parent.mkdir(exist_ok=True)
|
||||
|
||||
def build_index():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_images (
|
||||
name TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
for img_path in IMAGE_DIR.rglob("*.jpg"):
|
||||
card_name = img_path.stem.rsplit("_", 1)[0].replace("_", " ")
|
||||
conn.execute("INSERT OR REPLACE INTO card_images VALUES (?, ?)",
|
||||
(card_name, str(img_path)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
build_index()
|
||||
```
|
||||
|
||||
### Step 3 — Card Enricher (`api/card_enricher.py`)
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path("data/card_images.db")
|
||||
STATIC_URL = "http://localhost:8000/static/card-images"
|
||||
|
||||
def enrich_card(card: dict) -> dict:
|
||||
"""Inject local art_url into every CardDTO before sending to Unity."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.execute("SELECT path FROM card_images WHERE name = ?", (card["name"],))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
relative = Path(row[0]).relative_to("static/card-images")
|
||||
card["art_url"] = f"{STATIC_URL}/{relative}"
|
||||
else:
|
||||
card["art_url"] = f"{STATIC_URL}/card_back.jpg"
|
||||
return card
|
||||
```
|
||||
|
||||
### Step 4 — FastAPI Static Mount (`main.py` addition)
|
||||
|
||||
```python
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
app.mount("/static/card-images", StaticFiles(directory="static/card-images"), name="card-images")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Forge Bridge (Rules Engine)
|
||||
|
||||
### ForgeSubprocessManager (`forge/subprocess_manager.py`)
|
||||
|
||||
```python
|
||||
import subprocess, json, asyncio, threading
|
||||
from pathlib import Path
|
||||
|
||||
FORGE_JAR = Path("forge/forge-game.jar")
|
||||
|
||||
class ForgeSubprocessManager:
|
||||
def __init__(self):
|
||||
self.process = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self, deck_paths: list[str], format: str = "commander"):
|
||||
cmd = [
|
||||
"java", "-jar", str(FORGE_JAR),
|
||||
"--headless",
|
||||
"--format", format,
|
||||
"--decks", *deck_paths,
|
||||
"--output", "json"
|
||||
]
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
def read_state(self) -> dict:
|
||||
with self._lock:
|
||||
line = self.process.stdout.readline()
|
||||
return json.loads(line)
|
||||
|
||||
def send_action(self, action: dict):
|
||||
with self._lock:
|
||||
self.process.stdin.write(json.dumps(action) + "\n")
|
||||
self.process.stdin.flush()
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
```
|
||||
|
||||
### Data Transfer Objects (`api/dtos.py`)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class CardDTO(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
power: Optional[str]
|
||||
toughness: Optional[str]
|
||||
mana_cost: str
|
||||
art_url: str = ""
|
||||
tapped: bool = False
|
||||
zone: str = "hand"
|
||||
|
||||
class PlayerDTO(BaseModel):
|
||||
id: str
|
||||
life: int
|
||||
commander_damage: dict[str, int]
|
||||
commander_tax: int
|
||||
hand: list[CardDTO]
|
||||
battlefield: list[CardDTO]
|
||||
graveyard: list[CardDTO]
|
||||
exile: list[CardDTO]
|
||||
command_zone: list[CardDTO]
|
||||
library_count: int
|
||||
mana_pool: dict[str, int]
|
||||
is_ai: bool
|
||||
personality: Optional[str]
|
||||
|
||||
class GameStateDTO(BaseModel):
|
||||
game_id: str
|
||||
phase: str
|
||||
active_player: str
|
||||
priority: str
|
||||
turn_number: int
|
||||
players: list[PlayerDTO]
|
||||
stack: list[dict]
|
||||
legal_actions: list[dict]
|
||||
winner: Optional[str]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — FastAPI WebSocket Game Server
|
||||
|
||||
### Game Loop (`api/game_loop.py`)
|
||||
|
||||
```python
|
||||
import asyncio, uuid
|
||||
from .dtos import GameStateDTO
|
||||
from forge.subprocess_manager import ForgeSubprocessManager
|
||||
from ai.ai_orchestrator import get_ai_action
|
||||
from ai.personality import assign_personalities
|
||||
from api.card_enricher import enrich_card
|
||||
|
||||
active_games: dict[str, dict] = {}
|
||||
|
||||
async def start_game(deck_paths: list[str], ai_players: list[str]) -> str:
|
||||
game_id = str(uuid.uuid4())
|
||||
forge = ForgeSubprocessManager()
|
||||
forge.start(deck_paths)
|
||||
|
||||
# Assign AI personalities at game creation
|
||||
archetypes = detect_archetypes(deck_paths, ai_players) # ChromaDB query
|
||||
personalities = assign_personalities(ai_players, archetypes)
|
||||
|
||||
active_games[game_id] = {
|
||||
"forge": forge,
|
||||
"ai_players": ai_players,
|
||||
"personalities": personalities,
|
||||
"websockets": []
|
||||
}
|
||||
asyncio.create_task(game_loop(game_id))
|
||||
return game_id
|
||||
|
||||
async def game_loop(game_id: str):
|
||||
game = active_games[game_id]
|
||||
forge = game["forge"]
|
||||
|
||||
while True:
|
||||
raw_state = forge.read_state()
|
||||
if raw_state.get("winner"):
|
||||
await broadcast(game_id, raw_state)
|
||||
break
|
||||
|
||||
# Enrich every card with local art_url
|
||||
state = enrich_state(raw_state)
|
||||
await broadcast(game_id, state)
|
||||
|
||||
priority_player = state["priority"]
|
||||
if priority_player in game["ai_players"]:
|
||||
personality = game["personalities"][priority_player]
|
||||
action = await get_ai_action(state, priority_player, personality)
|
||||
forge.send_action(action)
|
||||
# else: wait for Unity client to send action via WebSocket
|
||||
|
||||
async def broadcast(game_id: str, state: dict):
|
||||
game = active_games[game_id]
|
||||
for ws in game["websockets"]:
|
||||
await ws.send_json(state)
|
||||
|
||||
def enrich_state(state: dict) -> dict:
|
||||
for player in state["players"]:
|
||||
for zone in ["hand", "battlefield", "graveyard", "exile", "command_zone"]:
|
||||
player[zone] = [enrich_card(c) for c in player.get(zone, [])]
|
||||
return state
|
||||
```
|
||||
|
||||
### WebSocket Endpoint (`api/routes/game.py`)
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from api.game_loop import active_games, start_game
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_ws(websocket: WebSocket, game_id: str):
|
||||
await websocket.accept()
|
||||
game = active_games.get(game_id)
|
||||
if not game:
|
||||
await websocket.close(code=4004)
|
||||
return
|
||||
game["websockets"].append(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
# Human player action forwarded directly to Forge
|
||||
game["forge"].send_action(data)
|
||||
except WebSocketDisconnect:
|
||||
game["websockets"].remove(websocket)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Unity TCGEngine Board (2D)
|
||||
|
||||
### Project Layout
|
||||
|
||||
```
|
||||
unity-commander-ui/
|
||||
├── Assets/
|
||||
│ ├── TCGEngine/ ← TCGEngine base (unmodified)
|
||||
│ ├── Commander/
|
||||
│ │ ├── Scripts/
|
||||
│ │ │ ├── GameNetworkManager.cs ← WebSocket ↔ FastAPI
|
||||
│ │ │ ├── CommanderBoardUI.cs ← 4-player layout controller
|
||||
│ │ │ ├── PlayerAreaUI.cs ← per-player zone panel
|
||||
│ │ │ ├── CardImageLoader.cs ← local cache texture fetcher
|
||||
│ │ │ ├── ZonePanel.cs ← Hand/Library/Graveyard/Exile/Command
|
||||
│ │ │ └── AIThinkingIndicator.cs ← spinner while AI brain is deciding
|
||||
│ │ └── Prefabs/
|
||||
│ │ ├── CardPrefab.prefab
|
||||
│ │ ├── PlayerAreaPrefab.prefab
|
||||
│ │ └── ZonePanelPrefab.prefab
|
||||
│ └── Scenes/
|
||||
│ └── CommanderBoard.unity
|
||||
```
|
||||
|
||||
### Board Layout (matches screenshot)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 3) │
|
||||
│ [COMMAND] │
|
||||
├──────────────────────────────┬───────────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ BATTLEFIELD │ BATTLEFIELD │
|
||||
│ (Player 1) │ (Player 2) │
|
||||
│ BATTLEFIELD (center) │
|
||||
│ BATTLEFIELD │ BATTLEFIELD │
|
||||
│ (Player 4) │ (Player 3) │
|
||||
│ │ │
|
||||
├──────────────────────────────┴───────────────────────────────────┤
|
||||
│ [COMMAND] │
|
||||
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 1) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### GameNetworkManager.cs
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using NativeWebSocket;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
public class GameNetworkManager : MonoBehaviour
|
||||
{
|
||||
public static GameNetworkManager Instance { get; private set; }
|
||||
|
||||
[SerializeField] private string serverUrl = "ws://localhost:8000/ws/game/";
|
||||
[SerializeField] private string gameId;
|
||||
|
||||
private WebSocket _socket;
|
||||
|
||||
public delegate void GameStateReceived(GameStateDTO state);
|
||||
public event GameStateReceived OnGameStateReceived;
|
||||
|
||||
async void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
_socket = new WebSocket($"{serverUrl}{gameId}");
|
||||
|
||||
_socket.OnMessage += (bytes) =>
|
||||
{
|
||||
var json = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
var state = JsonConvert.DeserializeObject<GameStateDTO>(json);
|
||||
UnityMainThreadDispatcher.Enqueue(() => OnGameStateReceived?.Invoke(state));
|
||||
};
|
||||
|
||||
await _socket.Connect();
|
||||
}
|
||||
|
||||
public async void SendAction(object action)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(action);
|
||||
await _socket.SendText(json);
|
||||
}
|
||||
|
||||
void Update() => _socket?.DispatchMessageQueue();
|
||||
|
||||
async void OnDestroy() => await _socket?.Close();
|
||||
}
|
||||
```
|
||||
|
||||
### CardImageLoader.cs
|
||||
|
||||
```csharp
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class CardImageLoader : MonoBehaviour
|
||||
{
|
||||
private static Dictionary<string, Texture2D> _cache = new();
|
||||
|
||||
public static IEnumerator LoadCardArt(string artUrl, RawImage target)
|
||||
{
|
||||
if (_cache.TryGetValue(artUrl, out var cached))
|
||||
{
|
||||
target.texture = cached;
|
||||
yield break;
|
||||
}
|
||||
|
||||
using var req = UnityWebRequestTexture.GetTexture(artUrl);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
_cache[artUrl] = tex;
|
||||
target.texture = tex;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Failed to load card art: {artUrl}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AIThinkingIndicator.cs
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// Shows a spinner + brain label while the AI is deciding.
|
||||
public class AIThinkingIndicator : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject spinnerPanel;
|
||||
[SerializeField] private Text brainLabel;
|
||||
[SerializeField] private Text personalityLabel;
|
||||
|
||||
public void Show(string brain, string personality)
|
||||
{
|
||||
spinnerPanel.SetActive(true);
|
||||
brainLabel.text = $"🧠 {brain}";
|
||||
personalityLabel.text = $"🎭 {personality}";
|
||||
}
|
||||
|
||||
public void Hide() => spinnerPanel.SetActive(false);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Commander Zone UIs
|
||||
|
||||
### Zone Definitions per Player
|
||||
|
||||
| Zone Panel | Content | Notes |
|
||||
|---|---|---|
|
||||
| `LIBRARY` | Count only | Click → draw/look at top |
|
||||
| `GRAVEYARD` | Scrollable card list | Full card art on hover |
|
||||
| `EXILE` | Scrollable card list | Includes face-down indicators |
|
||||
| `HAND` | Wide panel, card art | Hidden to opponents |
|
||||
| `COMMAND` | Commander card(s) | Shows commander tax counter |
|
||||
| `BATTLEFIELD` | Open grid | Tapped/untapped state, counters |
|
||||
|
||||
### Commander-Specific UI Elements
|
||||
|
||||
```csharp
|
||||
public class CommanderZoneUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Text taxCounter; // "Tax: +{2N}"
|
||||
[SerializeField] private RawImage commanderArt;
|
||||
[SerializeField] private Text commanderDamageDealt; // per opponent
|
||||
|
||||
public void Refresh(PlayerDTO player)
|
||||
{
|
||||
int tax = player.CommanderTax;
|
||||
taxCounter.text = tax > 0 ? $"+{tax * 2}" : "";
|
||||
|
||||
if (player.CommandZone.Count > 0)
|
||||
StartCoroutine(CardImageLoader.LoadCardArt(
|
||||
player.CommandZone[0].ArtUrl, commanderArt));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Commander Rules Validation Layer
|
||||
|
||||
Forge handles all rules natively. FastAPI validates the AI response is within the legal actions list Forge returns — no custom rules code needed in Python or Unity.
|
||||
|
||||
Key Commander rules confirmed handled by Forge headless:
|
||||
- **Commander Tax** — +2 generic per previous cast from command zone
|
||||
- **21 Commander Damage** — tracked per attacker/defender pair
|
||||
- **40 Starting Life**
|
||||
- **Zone Redirect** — commander goes to command zone instead of graveyard/exile (player choice)
|
||||
- **Color Identity** — deck validation at game start
|
||||
- **Singleton** — deck validation at game start
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — AI Decision Logging & PPO Pipeline
|
||||
|
||||
Every AI decision written to `logs/ai_decisions.jsonl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1743520800.123,
|
||||
"player_id": "player_2",
|
||||
"brain": "deepseek-r1:14b",
|
||||
"personality": "aggro_beatdown",
|
||||
"phase": "declare_attackers",
|
||||
"action": {"action": "attack", "card_id": "c_042", "targets": ["player_1"], "reasoning": "Open attack, no blockers visible"},
|
||||
"latency_ms": 847
|
||||
}
|
||||
```
|
||||
|
||||
This log becomes the **supervised learning dataset** for the next PPO training iteration. Each (state, action) pair from winning games is a positive training example.
|
||||
|
||||
---
|
||||
|
||||
## Project Directory Structure
|
||||
|
||||
```
|
||||
commander-ai-lab/
|
||||
├── main.py ← FastAPI app, mounts static files
|
||||
├── api/
|
||||
│ ├── dtos.py ← Pydantic DTOs
|
||||
│ ├── card_enricher.py ← Injects art_url into CardDTOs
|
||||
│ ├── game_loop.py ← Main game orchestration loop
|
||||
│ └── routes/
|
||||
│ └── game.py ← WebSocket + REST endpoints
|
||||
├── ai/
|
||||
│ ├── ai_orchestrator.py ← Main AI decision entry point
|
||||
│ ├── brain_selector.py ← DeepSeek-R1 vs GPT-OSS routing
|
||||
│ ├── prompt_builder.py ← Game state + RAG → prompt
|
||||
│ └── personality.py ← 8 personalities + assignment
|
||||
├── forge/
|
||||
│ └── subprocess_manager.py ← Forge JVM stdin/stdout bridge
|
||||
├── scripts/
|
||||
│ ├── download_card_images.py ← One-time Scryfall bulk download
|
||||
│ └── build_image_index.py ← SQLite name→path index
|
||||
├── static/
|
||||
│ └── card-images/ ← ~2GB local image cache
|
||||
│ └── {set_code}/
|
||||
│ └── {CardName}_{num}.jpg
|
||||
├── data/
|
||||
│ └── card_images.db ← SQLite image index
|
||||
├── logs/
|
||||
│ └── ai_decisions.jsonl ← PPO training dataset
|
||||
├── unity-commander-ui/ ← Unity 2D TCGEngine project
|
||||
└── docs/
|
||||
└── implementation_plan.md ← This document
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phased Timeline
|
||||
|
||||
| Phase | Scope | Est. Time |
|
||||
|---|---|---|
|
||||
| **0** | Scryfall image download + SQLite index + FastAPI static mount | 1 day |
|
||||
| **1** | Forge subprocess bridge + DTO definitions | 2–3 days |
|
||||
| **2** | AI Orchestrator + brain selector + prompt builder + personality system | 3–4 days |
|
||||
| **3** | Unity TCGEngine project setup + WebSocket GameNetworkManager | 3–4 days |
|
||||
| **4** | PlayerAreaUI + ZonePanels + CardImageLoader + board layout | 3–4 days |
|
||||
| **5** | Commander zone UI (tax counter, commander damage, zone redirect) | 1–2 days |
|
||||
| **6** | AI decision logging + end-to-end game loop test | 1–2 days |
|
||||
|
||||
**Total estimated: ~3 weeks to first playable Commander game with live AI opponents.**
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Forge = rules authority | Forge already handles all Commander rules natively. No custom rules code. |
|
||||
| TCGEngine = dumb renderer | All game logic lives in Forge. Unity only renders state it receives. |
|
||||
| FastAPI = hub | Bridges Forge ↔ Unity ↔ AI. Single point of truth. |
|
||||
| Dual-brain routing | DeepSeek-R1 excels at analytical/math decisions; GPT-OSS at strategic sequencing. |
|
||||
| ChromaDB scoped per player | RAG context relevant to the active player's deck, not global card knowledge. |
|
||||
| Personality persists per game | Consistent playstyle per AI across all 4 players per session. |
|
||||
| Local image cache | Eliminates network latency for card art. ~2GB one-time download. |
|
||||
| ai_decisions.jsonl | Every AI decision is a future PPO training sample. |
|
||||
@@ -1,32 +0,0 @@
|
||||
# Forge in the cluster image
|
||||
|
||||
The batch simulator on the Simulator page runs on **Forge**, the Java rules engine. The first cluster builds left it out by mistake ("Python-only"
|
||||
was read as "no Java"), so the page answered `Forge JAR not found`. It is now built into the image (reference copy of the Dockerfile stage:
|
||||
`infrastructure/apps/commander-lab/image/Dockerfile` in the farmhouse-os repo).
|
||||
|
||||
What the image build does:
|
||||
|
||||
1. **Stage 1** (`maven:3.9-eclipse-temurin-17`): fetch the official `Card-Forge/forge` at commit `edb28158f6d8` (the commit `KoalaTrapLord/Forge-Repo`
|
||||
is on; that fork is 0 commits ahead of upstream, so this is the code the lab's Java was written against; newer Forge may have changed APIs), then
|
||||
`mvn install -pl forge-gui-desktop -am -DskipTests -Drevision=2.0.12-SNAPSHOT`, retried up to three times (Maven Central downloads fail now and then).
|
||||
2. **The lab's Java tool** (`LabCli`, from this repository's `pom.xml`) is built against it. Forge's installed poms keep `${revision}` as their parent
|
||||
version, which Maven cannot resolve for a consumer even with `-Drevision`; the placeholder is rewritten in the installed `.pom` files first.
|
||||
3. **Runtime**: the full `openjdk-17-jre` (not `-headless`) plus `xvfb`, the two jars, and Forge's `res/` data without `adventure`, `music`, `sound`, `quest` and `skins`
|
||||
(about 190 MB the headless simulator never reads). The environment the app reads: `FORGE_JAR`, `FORGE_DIR`, `LAB_JAR`, `FORGE_DECKS_DIR`, `PRECON_DIR`, `JAVA_HOME`.
|
||||
4. `FORGE_MAX_THREADS` (default 1) clamps the batch's thread count: every Forge game is its own JVM with up to a 4 GB heap, and the cluster has two small nodes.
|
||||
5. CI starts the app, begins a batch through the app's own `/api/lab/start` and requires a Commander game with turns played and a readable `/api/lab/result` before the image is published.
|
||||
|
||||
## Things that only showed up by running it in the cluster (2026-10-27)
|
||||
Each of these made a batch fail while every individual piece looked installed. Together they are why the CI test starts a batch through the app's own API.
|
||||
- **Forge needs a display, even for `sim`.** `GuiDesktop`'s static initialiser asks AWT for the default screen device; the headless JRE throws `HeadlessException`, and
|
||||
`forge.view.Main` then exits 1 with no output at all (the lab tool records every game as an instant draw: "Forge produced NO output"). Fix: the full `openjdk-17-jre`,
|
||||
`xvfb`, `DISPLAY=:99`, and the entrypoint starts `Xvfb :99`.
|
||||
- **Three folders, one deck.** The app writes saved decks to `USER_DECKS_DIR`, the lab's Java tool resolves deck files through `PRECON_DECKS_DIR`, and Forge finds a deck BY
|
||||
NAME in its own commander folder ("Could not load deck"). All three are the Forge folder (`FORGE_DECKS_DIR`), which the entrypoint seeds with the 163 bundled precons.
|
||||
- **Writable folders.** The tool writes `results/` beside Forge's data and Forge a debug log beside its jar; both folders are owned by uid 10001 in the image.
|
||||
- **Result path.** The app asked for `results/ml-decision-<id>.json` (relative) but ran the tool from Forge's folder, so the file landed there and `/api/lab/result` answered
|
||||
"Result file not found". The path is made absolute (patch `forge-results-abs`) and `/app/results` is linked to `/state/results`.
|
||||
- `docker cp` cannot write into a tmpfs and Docker mounts `/tmp` `noexec` (it stops SQLite's native library loading): test-harness traps, not cluster problems.
|
||||
|
||||
Speed: about 25 seconds per game, one at a time (the project's own figure is ~0.04 simulations/s single-threaded). For many games with real rules, the
|
||||
phase-rs engine (`farmhouse/phase/`) is far faster. Forge is GPL-3.0: private use, do not redistribute the built image.
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Keep the app's state on the persistent volume (/state) while the code stays in the image.
|
||||
# The upstream app writes next to its code: collection.db, scryfall_cache.db, data/ (Scryfall bulk DB, ChromaDB index),
|
||||
# user-decks/. Point each at /state with a symlink; seed data/ and user-decks/ from the image the first time.
|
||||
set -eu
|
||||
S="${STATE_DIR:-/state}"
|
||||
mkdir -p "$S"
|
||||
|
||||
for d in data user-decks results; do
|
||||
if [ ! -e "$S/$d" ]; then
|
||||
if [ -d "/app/$d" ]; then cp -a "/app/$d" "$S/$d"; else mkdir -p "$S/$d"; fi
|
||||
fi
|
||||
rm -rf "/app/$d"
|
||||
ln -s "$S/$d" "/app/$d"
|
||||
done
|
||||
|
||||
for f in collection.db scryfall_cache.db; do
|
||||
rm -f "/app/$f"
|
||||
ln -s "$S/$f" "/app/$f" # sqlite creates the target on first open
|
||||
done
|
||||
|
||||
# Forge looks decks up by name in its user folder; the app exports the decks it simulates there.
|
||||
mkdir -p "${FORGE_DECKS_DIR:-/tmp/.forge/decks/commander}"
|
||||
# Seed it with the bundled precon decks (163 small files); never overwrite a deck that is already there.
|
||||
if [ -d /app/precon-decks ]; then cp -n /app/precon-decks/*.dck "${FORGE_DECKS_DIR:-/tmp/.forge/decks/commander}"/ 2>/dev/null || true; fi
|
||||
|
||||
# A virtual display for Forge (it initialises AWT even in command-line mode; see the Dockerfile). Best effort: the app itself does not need it.
|
||||
if command -v Xvfb >/dev/null 2>&1; then
|
||||
mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix 2>/dev/null || true
|
||||
Xvfb "${DISPLAY:-:99}" -screen 0 1280x800x24 -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[ -S "/tmp/.X11-unix/X${DISPLAY#:}" ] && break
|
||||
sleep 0.3
|
||||
done
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -1,34 +0,0 @@
|
||||
# Laya (https://github.com/NandhaKishorM/laya, Apache-2.0): a "decision model". It answers typed multiple-choice / yes-no / score
|
||||
# questions about a piece of text in ONE forward pass (about 33 ms on a GPU) and never writes text. Here it is staged for the Commander
|
||||
# AI Lab simulator, which asks an LLM "which action?" about 25 times per game; a small classifier can answer that class of question
|
||||
# far faster, if it is accurate enough (it is measured in shadow mode first, see ../README.md).
|
||||
#
|
||||
# CPU only. The weights are baked in at a pinned Hugging Face commit and the pod has NO network access (NetworkPolicy), so the
|
||||
# running service fetches nothing. Two checkpoints are included so they can be compared: the English base and the fine-tuned
|
||||
# "typed-decisions" one (about 840 MB each).
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
ARG LAYA_VERSION=0.4.15
|
||||
ARG LAYA_HF_REVISION=7b928d828b7b0e022f929d9bd2e44165aa270148
|
||||
|
||||
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1 PYTHONUNBUFFERED=1 HF_HOME=/opt/hf
|
||||
|
||||
RUN pip install torch --index-url https://download.pytorch.org/whl/cpu \
|
||||
&& pip install "laya[serve]==${LAYA_VERSION}"
|
||||
|
||||
# Weights at the pinned commit. HF's offline lookup resolves the "main" ref, which a commit-pinned download does not create, so it is
|
||||
# written by hand to the same commit.
|
||||
RUN python -c "from huggingface_hub import snapshot_download; snapshot_download('convaiinnovations/laya', revision='${LAYA_HF_REVISION}', ignore_patterns=['multilingual/*','assets/*','eval/*'])" \
|
||||
&& mkdir -p /opt/hf/hub/models--convaiinnovations--laya/refs \
|
||||
&& printf '%s' "${LAYA_HF_REVISION}" > /opt/hf/hub/models--convaiinnovations--laya/refs/main \
|
||||
&& chmod -R a+rX /opt/hf
|
||||
|
||||
RUN useradd --uid 10001 --no-create-home --shell /usr/sbin/nologin laya
|
||||
USER 10001
|
||||
|
||||
ENV HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 \
|
||||
LAYA_HOST=0.0.0.0 LAYA_PORT=8080 \
|
||||
LAYA_MODELS=english,typed-decisions LAYA_DEFAULT_MODEL=english LAYA_MAX_LOADED=2 LAYA_PRELOAD=1 LAYA_THREADS=4
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["laya-serve"]
|
||||
@@ -1,52 +0,0 @@
|
||||
# Laya as the simulator's "which action?" model: evaluation, 2026-10-26
|
||||
|
||||
**Result: not usable as shipped.** Both checkpoints agreed with the LLM less often than a model that always answers the commonest action.
|
||||
|
||||
## What was measured
|
||||
The simulator's LLM brain ("twin-turbo") picks one of nine actions about 25 times a game. A decision model (Laya, or Kev; both serve
|
||||
`POST /v1/systemone`) could answer that kind of question in one forward pass instead of a 7-second generation. To find out whether it
|
||||
could stand in, every LLM decision the app had recorded (`/app/logs/decisions/*.jsonl`: the game state and the action the LLM chose)
|
||||
was replayed through Laya with the same question, and the answers compared. The LLM is the *teacher*, not the truth: this measures
|
||||
whether a fast model can imitate the slow one, not which plays better.
|
||||
|
||||
Script: `image/farmhouse_decision_eval.py` (in the app image as `/app/scripts/farmhouse_decision_eval.py`):
|
||||
`DECISION_URL=http://laya:8080 python scripts/farmhouse_decision_eval.py /app/logs/decisions english typed-decisions`
|
||||
|
||||
## Numbers (149 recorded decisions)
|
||||
LLM's choices: play_land 41, hold 31, attack_all 28, cast_creature 20, cast_spell 13, attack_safe 7, cast_removal 5, cast_ramp 4.
|
||||
|
||||
| | agrees with the LLM | answers | time per answer | what it picked |
|
||||
|---|---|---|---|---|
|
||||
| always "play_land" (baseline) | **27.5%** | 149 | 0 | |
|
||||
| `english` (ModernBERT-large, base) | 16.1% | 149 | 1.9 s | cast_creature 105, attack_all 26; never hold, almost never play_land |
|
||||
| `typed-decisions` (fine-tuned) | 18.6% | 140 (9 timed out at 5 s) | ~4 s (shared CPU) | cast_creature 99, attack_all 26 |
|
||||
|
||||
It could say "cast_creature" and "attack_all" and little else; it never recognised "hold" (31 of the LLM's choices) or a removal spell.
|
||||
|
||||
## Why, and caveats
|
||||
- The shipped checkpoints are trained on generic ticket-triage style decisions. Laya's own README says fine-tuning "on decisions from your
|
||||
own domain is where accuracy jumps" (0.766 against 0.362 for the base on its own benchmark). This game's decisions were never in its training.
|
||||
- Speed on this cluster's CPU (4 threads) was 2-4 s per answer, not the ~33 ms quoted for a GPU. Against the LLM's ~7.5 s that is not the
|
||||
speed-up that was hoped for either, without a GPU.
|
||||
- The question wording (the nine action descriptions) and the compact text it reads (`farmhouse_decide.compact_state`) are mine; a
|
||||
better prompt might help a little, not from 18% to usable.
|
||||
- Part of the recorded data predates the card-data fix (v10), so some states had thinner hands than they would now.
|
||||
- The LLM being the "truth" also caps the result: where the LLM is wrong, agreeing with it is not a win.
|
||||
|
||||
## Where this leaves it
|
||||
- Laya is **staged but scaled to 0 replicas** (`deployment-laya.yaml`: `replicas: 0`; set to 1 to run it). The image
|
||||
(`ghcr.io/fez5587/farmhouse-laya:v1`, offline, weights baked in), the service and the network policy remain.
|
||||
- The app is wired to use it (`DECISION_URL`, `DECISION_MODE`) and `DECISION_MODE` is `off`. `shadow` records agreement without
|
||||
affecting games; `assist` uses it when it is `DECISION_MIN_CONF` sure. Kev speaks the same API, so swapping is a URL.
|
||||
- To make it worth using: fine-tune on this game's decisions (Laya ships a Kaggle 2xT4 notebook, about 4 hours, and an Apple/CPU
|
||||
script). The labels would be the LLM's recorded decisions; a few thousand are needed, so generate them first (LLM games at ~7 s a
|
||||
decision, or label states with the engine's own heuristic for the cheap, abundant version). Then re-run this evaluation.
|
||||
- Node memory: it needed about 2 GB (3.6 GB with both checkpoints, which pushed the control-plane node to 92%; it is pinned to the other node).
|
||||
|
||||
## Later the same day: the new connections (see docs/LAYA-CONNECTIONS.md in the fork)
|
||||
- **Forge-shaped priority choice** (CSIE's ranking in the text, three scenarios): agreed with CSIE each time, 42-48% sure, 7.5-8.4 s per call on the CPU against Forge's
|
||||
1.5 s budget. Not usable on a CPU; the code falls back silently.
|
||||
- **Card roles from Oracle text** (150 unambiguous cards, 6 roles present, 9 offered): 48% against 17% by chance (counterspell 76%, board wipe 64%, removal 40%, tutor 40%,
|
||||
ramp 36%, card draw 32%), 3.2 s per card in batches. Much better than the action choice; useful as on-demand evidence for the cards in a hand, too slow to tag the whole pool.
|
||||
- **Extra questions** in the same call: "threat" said *high* for everything; "must answer" gave 0.48 for a lethal stack against 0.25-0.29 otherwise (right direction, weak).
|
||||
- Capture, dataset export and the rest are built and off by default; fine-tuning on the captured decisions is the path to making it useful.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Laya in this repository
|
||||
|
||||
Laya (https://github.com/NandhaKishorM/laya, Apache-2.0) is a **decision model**: it answers typed multiple-choice, yes/no and score
|
||||
questions about a piece of text in one forward pass and never writes text. Here it is a fast second opinion beside the LLM and CSIE.
|
||||
Kev (https://github.com/jaredpalmer/kev) serves the same `POST /v1/systemone` API, so swapping is a URL change (Kev needs a GPU).
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `Dockerfile` | CPU image: `laya[serve]` 0.4.15 + CPU torch, English and `typed-decisions` checkpoints baked in at Hugging Face commit `7b928d8`; runs offline |
|
||||
| `deployment-laya.yaml` | Kubernetes Deployment + Service + NetworkPolicy (no egress; only the app may call it). Currently `replicas: 0` |
|
||||
| `build-farmhouse-laya.workflow.yml` | the CI workflow that builds and smoke-tests the image offline (it lives in `.github/workflows/` of the farmhouse-os GitOps repo) |
|
||||
| `LAYA-EVALUATION.md` | why it is off: zero-shot both checkpoints agreed with the LLM less often than always guessing the commonest action |
|
||||
|
||||
The deployment source of truth is the farmhouse-os repo (`infrastructure/apps/commander-lab/`); these are reference copies so the
|
||||
fork is self-contained. Things learned running it:
|
||||
|
||||
- A Kubernetes Service named `laya` injects `LAYA_PORT=tcp://...`, which `laya-serve` reads as its own port setting and refuses. Set
|
||||
`enableServiceLinks: false` and `LAYA_PORT=8080` explicitly.
|
||||
- Two checkpoints resident use 3.6 GB; one uses about 1.9 GB. Do not place it on a control-plane node.
|
||||
- The CPU answers in 2-4 s, not the ~33 ms quoted for a GPU.
|
||||
- How the code in this repository connects to it, and how to fine-tune it: `docs/LAYA-CONNECTIONS.md`.
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Build FarmHouse Laya
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "infrastructure/apps/commander-lab/laya/**"
|
||||
- ".github/workflows/build-farmhouse-laya.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/farmhouse-laya
|
||||
# Bump together with the image tag in infrastructure/apps/commander-lab/deployment-laya.yaml.
|
||||
IMAGE_TAG: v1
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image name lowercase
|
||||
shell: bash
|
||||
run: |
|
||||
echo "IMAGE=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build validation image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: infrastructure/apps/commander-lab/laya
|
||||
load: true
|
||||
push: false
|
||||
tags: laya:test
|
||||
|
||||
# The service must answer with NO network (that is how it runs in the cluster) as the unprivileged user, for both checkpoints.
|
||||
- name: Smoke test (boots offline, answers a game-state question with both checkpoints)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run -d --name laya --network none laya:test
|
||||
cat > /tmp/ask.py <<'PY'
|
||||
import json, sys, time, urllib.request
|
||||
model = sys.argv[1]
|
||||
body = {"model": model,
|
||||
"state": {"body": "Turn 4. You have 4 lands untapped. Hand: Llanowar Elves, Swords to Plowshares, Forest. Opponent has a 6/6 creature attacking you."},
|
||||
"questions": {"action": {"type": "choice", "instructions": "Which action is best this turn?",
|
||||
"criteria": {"cast_creature": "put a creature onto the battlefield",
|
||||
"cast_removal": "destroy or exile an opposing permanent",
|
||||
"play_land": "play a land",
|
||||
"hold": "do nothing this turn"}}}}
|
||||
req = urllib.request.Request("http://127.0.0.1:8080/v1/systemone", data=json.dumps(body).encode(), headers={"content-type": "application/json"})
|
||||
t = time.time()
|
||||
out = json.load(urllib.request.urlopen(req, timeout=120))
|
||||
print(model, "%.0f ms" % ((time.time() - t) * 1000), json.dumps(out)[:600])
|
||||
assert "answers" in out and "action" in out["answers"], out
|
||||
PY
|
||||
docker cp /tmp/ask.py laya:/tmp/ask.py
|
||||
ok=0
|
||||
for i in $(seq 1 120); do
|
||||
if docker exec laya python -c "import socket; socket.create_connection(('127.0.0.1', 8080), 2)" 2>/dev/null; then ok=1; break; fi
|
||||
sleep 3
|
||||
done
|
||||
if [ "$ok" != 1 ]; then echo "laya did not open its port"; docker logs laya | tail -60; exit 1; fi
|
||||
for m in english typed-decisions; do docker exec laya python /tmp/ask.py "$m"; done
|
||||
docker exec laya id
|
||||
docker rm -f laya >/dev/null
|
||||
echo SMOKE OK
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and publish
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: infrastructure/apps/commander-lab/laya
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ env.IMAGE_TAG }}
|
||||
${{ env.IMAGE }}:${{ github.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
@@ -1,93 +0,0 @@
|
||||
# Laya, a CPU decision model (laya/Dockerfile), staged for the simulator. Reached only by the app, over the cluster network, on 8080.
|
||||
# No internet, no volume (weights are in the image), no service-account token.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: laya
|
||||
namespace: commander-lab
|
||||
labels: {app: laya}
|
||||
spec:
|
||||
# 0 since 2026-10-26: both checkpoints scored BELOW the always-guess baseline against the LLM's recorded decisions (English 16.1%, typed-decisions
|
||||
# 18.6%, baseline 27.5%, 149 states), and ~2 GB of the node's memory was needed for the card-search index build. Set to 1 to bring it back
|
||||
# (image, service and policy stay in place); it is only worth running once it is fine-tuned on this game's decisions (README).
|
||||
replicas: 0
|
||||
selector:
|
||||
matchLabels: {app: laya}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: laya}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
# Kubernetes injects LAYA_PORT=tcp://<ip>:8080 for a Service named laya, and laya-serve reads LAYA_PORT as its own port setting
|
||||
# ("invalid LAYA_PORT 'tcp://...'", crash loop). Service-link variables are not wanted here at all.
|
||||
enableServiceLinks: false
|
||||
# NOT on talos-gmw: that node runs the Kubernetes API server, and Laya's first placement there (two checkpoints, 3.6 GB) took it from
|
||||
# 68% to 92% memory on 2026-10-26.
|
||||
nodeSelector: {kubernetes.io/hostname: talos-noy-0r4}
|
||||
imagePullSecrets:
|
||||
- name: ghcr-pull
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile: {type: RuntimeDefault}
|
||||
containers:
|
||||
- name: laya
|
||||
image: ghcr.io/fez5587/farmhouse-laya:v1
|
||||
ports:
|
||||
- {name: http, containerPort: 8080}
|
||||
env:
|
||||
- {name: LAYA_THREADS, value: "4"}
|
||||
- {name: LAYA_PORT, value: "8080"}
|
||||
# One checkpoint only (about 1.9 GB): the English base scored below the always-guess baseline against the LLM; typed-decisions is the
|
||||
# fine-tuned one still being measured.
|
||||
- {name: LAYA_MODELS, value: "typed-decisions"}
|
||||
- {name: LAYA_DEFAULT_MODEL, value: "typed-decisions"}
|
||||
- {name: LAYA_MAX_LOADED, value: "1"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities: {drop: [ALL]}
|
||||
resources:
|
||||
requests: {cpu: "500m", memory: 2500Mi}
|
||||
limits: {cpu: "4", memory: 3Gi}
|
||||
# Loading two checkpoints takes a while; the port opens when the server is up.
|
||||
startupProbe:
|
||||
tcpSocket: {port: http}
|
||||
periodSeconds: 5
|
||||
failureThreshold: 90
|
||||
readinessProbe:
|
||||
tcpSocket: {port: http}
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
tcpSocket: {port: http}
|
||||
periodSeconds: 30
|
||||
failureThreshold: 6
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: laya
|
||||
namespace: commander-lab
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector: {app: laya}
|
||||
ports:
|
||||
- {name: http, port: 8080, targetPort: http}
|
||||
---
|
||||
# IN: only the app. OUT: nothing at all (the weights are in the image; it needs no DNS and calls no one).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: laya
|
||||
namespace: commander-lab
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels: {app: laya}
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels: {app: commander-lab}
|
||||
ports:
|
||||
- {protocol: TCP, port: 8080}
|
||||
egress: []
|
||||
@@ -1,61 +0,0 @@
|
||||
# phase-sim: rules-accurate four-player Commander games with phase-rs's headless AI (github.com/phase-rs/phase), behind a small HTTP service.
|
||||
#
|
||||
# phase is a Rust rules engine (stack, priority, combat, layers, triggers, replacement effects), dual licensed Apache-2.0 / MIT, with card
|
||||
# data from MTGJSON (MIT). Pinned to release v0.118.0 (commit ad0de6a) and to the card database that release was published with
|
||||
# (`card-data-5e541a38c99b97f0.json`, named in data.phase-rs.dev/card-data-meta.json for that commit); the file's SHA-256 is checked, so a
|
||||
# changed file fails the build instead of silently changing results. Nothing is fetched at run time, and the pod has no network.
|
||||
#
|
||||
# Built: the `ai-commander` binary (4-player Commander, batch mode) and `ai-duel` with the repo's `server-release` profile (unwinding
|
||||
# panics, so one game's panic cannot kill a batch). The toolchain is whatever rust-toolchain.toml pins (a dated nightly).
|
||||
FROM rust:slim-bookworm AS build
|
||||
|
||||
ARG PHASE_REPO=https://github.com/phase-rs/phase.git
|
||||
ARG PHASE_TAG=v0.118.0
|
||||
ARG PHASE_SHA=ad0de6adf00153a6e6eb89604f6670be5b1d5168
|
||||
ARG CARD_DATA_URL=https://data.phase-rs.dev/card-data-5e541a38c99b97f0.json
|
||||
ARG CARD_DATA_SHA256=5e541a38c99b97f0ef12c81f7b249e7cd253f38b32bd5c156cf19608cb6f1218
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl build-essential pkg-config python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir /phase; cd /phase; \
|
||||
git init -q; \
|
||||
git remote add origin "$PHASE_REPO"; \
|
||||
git fetch -q --depth 1 origin "$PHASE_SHA"; \
|
||||
git checkout -q FETCH_HEAD; \
|
||||
test "$(git rev-parse HEAD)" = "$PHASE_SHA"
|
||||
|
||||
WORKDIR /phase
|
||||
# phase's server-release profile uses thin LTO and 16 code-generation units; compiling its big engine crate that way ran a 7 GB CI machine
|
||||
# out of memory ("cannot allocate memory", 2026-10-27). LTO off and two parallel jobs trade some build time (and a few percent of run
|
||||
# speed) for a build that fits; the optimisation level and panic=unwind (which batch isolation needs) are unchanged.
|
||||
ENV CARGO_BUILD_JOBS=2 \
|
||||
CARGO_PROFILE_SERVER_RELEASE_LTO=off \
|
||||
CARGO_PROFILE_SERVER_RELEASE_CODEGEN_UNITS=8
|
||||
RUN cargo build --profile server-release -p phase-ai --bin ai-commander --bin ai-duel
|
||||
|
||||
RUN set -eux; \
|
||||
curl -fsSL --retry 3 -o /card-data.json "$CARD_DATA_URL"; \
|
||||
echo "$CARD_DATA_SHA256 /card-data.json" | sha256sum -c -; \
|
||||
cp /phase/client/public/feeds/mtggoldfish-commander.json /sample-feed.json
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
ARG PHASE_TAG=v0.118.0
|
||||
|
||||
RUN groupadd -g 10001 phase && useradd -u 10001 -g 10001 -d /tmp -s /usr/sbin/nologin phase
|
||||
COPY --from=build /phase/target/server-release/ai-commander /opt/phase/ai-commander
|
||||
COPY --from=build /phase/target/server-release/ai-duel /opt/phase/ai-duel
|
||||
COPY --from=build /card-data.json /opt/phase/data/card-data.json
|
||||
COPY --from=build /sample-feed.json /opt/phase/sample-feed.json
|
||||
COPY phase_sim.py phase_smoke.py /opt/phase/
|
||||
|
||||
ENV PHASE_ENGINE_VERSION="phase-rs ${PHASE_TAG}" \
|
||||
PHASE_AI_COMMANDER=/opt/phase/ai-commander \
|
||||
PHASE_CARD_DATA=/opt/phase/data/card-data.json \
|
||||
PYTHONUNBUFFERED=1 PORT=8090
|
||||
USER 10001
|
||||
WORKDIR /tmp
|
||||
EXPOSE 8090
|
||||
CMD ["python", "/opt/phase/phase_sim.py"]
|
||||
@@ -1,72 +0,0 @@
|
||||
# phase-sim: rules-accurate four-player Commander games with phase-rs's headless AI (github.com/phase-rs/phase), behind a small HTTP service.
|
||||
#
|
||||
# phase is a Rust rules engine (stack, priority, combat, layers, triggers, replacement effects), dual licensed Apache-2.0 / MIT, with card
|
||||
# data from MTGJSON (MIT). Pinned to release v0.118.0 (commit ad0de6a) and to the card database that release was published with
|
||||
# (`card-data-5e541a38c99b97f0.json`, named in data.phase-rs.dev/card-data-meta.json for that commit); the file's SHA-256 is checked, so a
|
||||
# changed file fails the build instead of silently changing results. Nothing is fetched at run time, and the pod has no network.
|
||||
#
|
||||
# Built: the `ai-commander` binary (4-player Commander, batch mode) and `ai-duel` with the repo's `server-release` profile (unwinding
|
||||
# panics, so one game's panic cannot kill a batch). The toolchain is whatever rust-toolchain.toml pins (a dated nightly).
|
||||
FROM rust:slim-bookworm AS build
|
||||
|
||||
ARG PHASE_REPO=https://github.com/phase-rs/phase.git
|
||||
ARG PHASE_TAG=v0.118.0
|
||||
ARG PHASE_SHA=ad0de6adf00153a6e6eb89604f6670be5b1d5168
|
||||
ARG CARD_DATA_URL=https://data.phase-rs.dev/card-data-5e541a38c99b97f0.json
|
||||
ARG CARD_DATA_SHA256=5e541a38c99b97f0ef12c81f7b249e7cd253f38b32bd5c156cf19608cb6f1218
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl build-essential pkg-config python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir /phase; cd /phase; \
|
||||
git init -q; \
|
||||
git remote add origin "$PHASE_REPO"; \
|
||||
git fetch -q --depth 1 origin "$PHASE_SHA"; \
|
||||
git checkout -q FETCH_HEAD; \
|
||||
test "$(git rev-parse HEAD)" = "$PHASE_SHA"
|
||||
|
||||
WORKDIR /phase
|
||||
# phase's server-release profile uses thin LTO and 16 code-generation units; compiling its big engine crate that way ran a 7 GB CI machine
|
||||
# out of memory ("cannot allocate memory", 2026-10-27). LTO off and two parallel jobs trade some build time (and a few percent of run
|
||||
# speed) for a build that fits; the optimisation level and panic=unwind (which batch isolation needs) are unchanged.
|
||||
ENV CARGO_BUILD_JOBS=2 \
|
||||
CARGO_PROFILE_SERVER_RELEASE_LTO=off \
|
||||
CARGO_PROFILE_SERVER_RELEASE_CODEGEN_UNITS=8
|
||||
RUN cargo build --profile server-release -p phase-ai --bin ai-commander --bin ai-duel
|
||||
|
||||
# ---- model seats (this Dockerfile only) -------------------------------------------------------------------------------------------
|
||||
# Everything above is identical to Dockerfile, so the long compile above comes from the build cache. These steps add `ai-commander-llm`: the
|
||||
# same runner with a hook that lets a language model or a decision model (Laya / Kev) choose for named seats among the engine's OWN legal
|
||||
# options. Two anchored edits to phase's source (apply_phase_patches.py: the build stops if phase moved either line) and one new crate.
|
||||
# phase-llm depends on phase-ai, so the hook cannot live in phase-ai's binary; the new crate depends on both and includes the runner.
|
||||
COPY llm-seat /llm-seat
|
||||
RUN python3 /llm-seat/apply_phase_patches.py /phase /llm-seat
|
||||
RUN cargo build --profile server-release -p farmhouse-llm-seat --bin ai-commander-llm
|
||||
|
||||
RUN set -eux; \
|
||||
curl -fsSL --retry 3 -o /card-data.json "$CARD_DATA_URL"; \
|
||||
echo "$CARD_DATA_SHA256 /card-data.json" | sha256sum -c -; \
|
||||
cp /phase/client/public/feeds/mtggoldfish-commander.json /sample-feed.json
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
ARG PHASE_TAG=v0.118.0
|
||||
|
||||
RUN groupadd -g 10001 phase && useradd -u 10001 -g 10001 -d /tmp -s /usr/sbin/nologin phase
|
||||
COPY --from=build /phase/target/server-release/ai-commander /opt/phase/ai-commander
|
||||
COPY --from=build /phase/target/server-release/ai-duel /opt/phase/ai-duel
|
||||
COPY --from=build /phase/target/server-release/ai-commander-llm /opt/phase/ai-commander-llm
|
||||
COPY --from=build /card-data.json /opt/phase/data/card-data.json
|
||||
COPY --from=build /sample-feed.json /opt/phase/sample-feed.json
|
||||
COPY phase_sim.py phase_smoke.py /opt/phase/
|
||||
|
||||
ENV PHASE_ENGINE_VERSION="phase-rs ${PHASE_TAG}" \
|
||||
PHASE_AI_COMMANDER=/opt/phase/ai-commander \
|
||||
PHASE_AI_COMMANDER_LLM=/opt/phase/ai-commander-llm \
|
||||
PHASE_CARD_DATA=/opt/phase/data/card-data.json \
|
||||
PYTHONUNBUFFERED=1 PORT=8090
|
||||
USER 10001
|
||||
WORKDIR /tmp
|
||||
EXPOSE 8090
|
||||
CMD ["python", "/opt/phase/phase_sim.py"]
|
||||
@@ -1,36 +0,0 @@
|
||||
# phase-sim: rules-accurate Commander games with phase-rs
|
||||
|
||||
Reference copies of what runs in the cluster (source of truth: `infrastructure/apps/commander-lab/` in the farmhouse-os repo). Background and the assessment
|
||||
that led here: `docs/PHASE-AND-PLAYTESTER.md`.
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `phase_sim.py` | the service: stdlib HTTP, one job at a time. `POST /run` four decks -> `GET /job/<id>` per-game results, wins by seat, 95% interval, warnings. Optional model seats and two loopback-only relays (`/llm/v1/chat/completions`, `/jev/systemone`) |
|
||||
| `Dockerfile` | builds phase-rs `v0.118.0` (`ai-commander`, `ai-duel`) and takes the card database that release was published with (SHA-256 checked) |
|
||||
| `Dockerfile.llm` | the same plus `ai-commander-llm`: model-driven seats |
|
||||
| `llm-seat/` | the Rust crate and the two anchored edits to phase's source that make model seats possible |
|
||||
| `phase_smoke.py`, `test_phase_sim.py` | CI: real games through the service; 49 offline checks against a stub engine |
|
||||
| `deployment-phase.yaml` | Deployment, Service and a NetworkPolicy (in: the app only; out: DNS, Olla and Laya only) |
|
||||
|
||||
The app side is `routes/farmhouse_phase.py` (four saved decks -> the service) and `ui/phase-sim.html`; both are real source in this repository, registered by
|
||||
`lab_api.py` and linked from the home page.
|
||||
|
||||
## Model seats: how they work and why they are safe
|
||||
phase's headless runner has no model support; the model seats exist only in phase's browser client. `phase-llm` (the crate) deliberately does no networking:
|
||||
it renders the engine's option list into a prompt, builds the HTTP request, and parses the reply, and the host performs the call. `llm-seat/` is that host
|
||||
for the headless runner:
|
||||
|
||||
1. `apply_phase_patches.py` adds a process-wide `ACTION_OVERRIDE` hook in `phase-ai/src/auto_play.rs` (asked before the built-in AI chooses) and makes the runner's `main` `pub`.
|
||||
2. The crate `farmhouse-llm-seat` includes the runner's source as a module (`#[path]`) and installs the hook. `phase-llm` depends on `phase-ai`, so the hook cannot
|
||||
live inside phase-ai's own binary.
|
||||
3. For each decision on a model seat: the engine issues its option list (`AiDecisionContract`); phase-llm builds the prompt (a forced move with one option is never asked);
|
||||
this crate makes the HTTP call (blocking, no TLS, 20 s limit); phase-llm turns the reply into one of the engine's options; the choice is checked against the contract
|
||||
here and again by `run_ai_action`. **Any** problem returns "no opinion" and phase's own AI plays the move. A cap on model calls per run (default 300) bounds the cost.
|
||||
4. The service supplies the endpoints through its own loopback-only relays: the chat relay switches the model's thinking off (twin-turbo otherwise thinks for tens of
|
||||
seconds a move) and caps the answer; the decision relay unwraps phase's `Jev` envelope and forwards the System One request to Laya or Kev.
|
||||
|
||||
The model never authors an action and never sees hidden cards beyond what phase-llm renders for that seat.
|
||||
|
||||
## Status
|
||||
Written 2026-10-27. The service logic is tested offline; the Rust crate has not compiled outside CI when this was written (no Rust toolchain on the workstation), so
|
||||
its first CI run is the first real test. Check `.github/workflows/build-farmhouse-phase-sim.yml` results in the farmhouse-os repo.
|
||||
@@ -1,111 +0,0 @@
|
||||
# phase-sim (phase/Dockerfile): rules-accurate four-player Commander games over a small HTTP API. Only the app may call it; it has no
|
||||
# network and no volume (engine and card data are in the image). One game at a time, CPU heavy.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: phase-sim
|
||||
namespace: commander-lab
|
||||
labels: {app: phase-sim}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: {app: phase-sim}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: phase-sim}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
# A Service named like the app injects PHASE_SIM_* variables into the pod; nothing here wants them (the same kind of collision
|
||||
# crash-looped Laya on LAYA_PORT).
|
||||
enableServiceLinks: false
|
||||
imagePullSecrets:
|
||||
- name: ghcr-pull
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
seccompProfile: {type: RuntimeDefault}
|
||||
containers:
|
||||
- name: phase-sim
|
||||
image: ghcr.io/fez5587/farmhouse-phase-sim:v1-llm # -llm = the baseline plus ai-commander-llm (model seats)
|
||||
ports:
|
||||
- {name: http, containerPort: 8090}
|
||||
env:
|
||||
- {name: PORT, value: "8090"}
|
||||
- {name: PHASE_SIM_MAX_GAMES, value: "50"}
|
||||
- {name: PHASE_SIM_TIMEOUT, value: "3600"}
|
||||
# Model seats. The engine talks to THIS service's loopback-only relays, which forward to these (see phase_sim.py): chat = an
|
||||
# OpenAI-style endpoint (Olla -> twin-turbo; the relay switches thinking off and caps the answer), decision = a System One server
|
||||
# (Laya / Kev; the deployment is scaled to 0 until it is fine-tuned, so a decision-seat run answers 502 and falls back to phase's AI).
|
||||
- {name: PHASE_LLM_CHAT_URL, value: "http://olla.ai-system.svc.cluster.local:40114/olla/openai/v1"}
|
||||
- {name: PHASE_LLM_DECISION_URL, value: "http://laya.commander-lab.svc.cluster.local:8080/v1/systemone"}
|
||||
- {name: PHASE_LLM_REASONING, value: "none"}
|
||||
- {name: PHASE_LLM_MAX_TOKENS_CAP, value: "256"}
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities: {drop: [ALL]}
|
||||
volumeMounts:
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
resources:
|
||||
requests: {cpu: "500m", memory: 1Gi}
|
||||
limits: {cpu: "3", memory: 3Gi}
|
||||
readinessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet: {path: /health, port: http}
|
||||
periodSeconds: 30
|
||||
failureThreshold: 6
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {sizeLimit: 512Mi}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: phase-sim
|
||||
namespace: commander-lab
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector: {app: phase-sim}
|
||||
ports:
|
||||
- {name: http, port: 8090, targetPort: http}
|
||||
---
|
||||
# IN: only the app. OUT: DNS and the two model endpoints the relays use (Olla for the chat model, Laya for the decision model); nothing else.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: phase-sim
|
||||
namespace: commander-lab
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels: {app: phase-sim}
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector:
|
||||
matchLabels: {app: commander-lab}
|
||||
ports:
|
||||
- {protocol: TCP, port: 8090}
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels: {kubernetes.io/metadata.name: kube-system}
|
||||
podSelector:
|
||||
matchLabels: {k8s-app: kube-dns}
|
||||
ports:
|
||||
- {protocol: UDP, port: 53}
|
||||
- {protocol: TCP, port: 53}
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels: {kubernetes.io/metadata.name: ai-system}
|
||||
podSelector:
|
||||
matchLabels: {app.kubernetes.io/name: olla}
|
||||
ports:
|
||||
- {protocol: TCP, port: 40114}
|
||||
- to:
|
||||
- podSelector:
|
||||
matchLabels: {app: laya}
|
||||
ports:
|
||||
- {protocol: TCP, port: 8080}
|
||||
@@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "farmhouse-llm-seat"
|
||||
version.workspace = true
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
# FarmHouse: phase's headless four-player Commander runner (`ai-commander`) with MODEL-DRIVEN SEATS. The runner's own source is included
|
||||
# as a module (src/main.rs, #[path]); this crate adds only the network transport phase-llm deliberately leaves to the host. phase-llm
|
||||
# depends on phase-ai, so the hook cannot live inside phase-ai's own binary: a new crate that depends on both is the way round that.
|
||||
|
||||
[[bin]]
|
||||
name = "ai-commander-llm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
engine = { path = "../engine", package = "phase-engine" }
|
||||
phase-ai = { path = "../phase-ai" }
|
||||
phase-llm = { path = "../phase-llm" }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
rand = "0.9"
|
||||
tracing = "0.1"
|
||||
mimalloc = "0.1"
|
||||
# Blocking HTTP, no TLS: the model endpoints are cluster-internal http:// relays (phase-sim's own /llm and /jev routes).
|
||||
ureq = { version = "2", default-features = false }
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Apply the FarmHouse edits to a phase-rs checkout (run in the Docker build, from the repository root).
|
||||
|
||||
python apply_phase_patches.py <phase-checkout> <llm-seat-crate-dir>
|
||||
|
||||
1. crates/phase-ai/src/auto_play.rs: a process-wide ACTION_OVERRIDE hook, asked before the built-in AI chooses. It returns Option<GameAction>;
|
||||
None (the default, and what every failure returns) leaves phase's own choice untouched. The chosen action still goes through the same
|
||||
contract check (`contract.permits`) as before.
|
||||
2. crates/phase-ai/src/bin/ai_commander.rs: `fn main` becomes `pub fn main` so the new crate can include and call it.
|
||||
3. The new crate is copied into crates/farmhouse-llm-seat (the workspace takes every crates/* member).
|
||||
|
||||
Every anchor must match exactly once or the build stops, so a phase update that moves a line is reported here, not in a compile error."""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
root, crate = sys.argv[1], sys.argv[2]
|
||||
|
||||
|
||||
def edit(rel, edits):
|
||||
path = os.path.join(root, rel)
|
||||
text = open(path, encoding="utf-8", newline="").read()
|
||||
for old, new in edits:
|
||||
n = text.count(old)
|
||||
if n != 1:
|
||||
raise SystemExit("%s: anchor found %d times (need 1): %r" % (rel, n, old[:80]))
|
||||
text = text.replace(old, new)
|
||||
open(path, "w", encoding="utf-8", newline="").write(text)
|
||||
print("patched", rel)
|
||||
|
||||
|
||||
HOOK = '''use crate::search::choose_action_with_session;
|
||||
|
||||
/// FarmHouse: an optional process-wide override, asked BEFORE the built-in AI chooses for a seat. A host (see crates/farmhouse-llm-seat)
|
||||
/// installs one to let a model pick among the engine's own options. It returns None to leave the choice to the built-in AI, which is also
|
||||
/// what it must return on any failure; whatever it returns still has to pass the same decision-contract check below.
|
||||
pub type ActionOverride = Box<
|
||||
dyn Fn(
|
||||
&engine::types::game_state::GameState,
|
||||
&engine::ai_support::AiDecisionContract,
|
||||
engine::types::player::PlayerId,
|
||||
engine::types::player::PlayerId,
|
||||
) -> Option<engine::types::actions::GameAction>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
static ACTION_OVERRIDE: std::sync::OnceLock<ActionOverride> = std::sync::OnceLock::new();
|
||||
|
||||
/// Install the override. Returns false if one is already installed (it can be set once per process).
|
||||
pub fn install_action_override(hook: ActionOverride) -> bool {
|
||||
ACTION_OVERRIDE.set(hook).is_ok()
|
||||
}
|
||||
'''
|
||||
|
||||
edit("crates/phase-ai/src/auto_play.rs", [
|
||||
("use crate::search::choose_action_with_session;\n", HOOK),
|
||||
(" let action = match choose_action_with_session(state, semantic_owner, config, rng, session) {\n Some(action) => action,\n None => {\n tracing::warn!(player = ?actor, \"choose_action returned None",
|
||||
" // The override's pick must be one the engine will really APPLY, not only one it lists: a model chose both modes of a modal spell whose\n"
|
||||
" // entwine cost it could not pay, the contract listed the option, apply refused it, and the whole game stalled (2026-10-27). Try it on a\n"
|
||||
" // copy of the game first; anything the engine refuses is dropped and the built-in AI plays the move. (Stack-pass rechecks have their\n"
|
||||
" // own verified path and are left alone.)\n"
|
||||
" let farmhouse_choice = ACTION_OVERRIDE\n .get()\n .and_then(|hook| hook(&*state, &contract, semantic_owner, actor))\n"
|
||||
" .filter(|candidate| {\n"
|
||||
" verified_ai_stack_pass_player(&*state, candidate).is_some()\n"
|
||||
" || apply_interaction(&mut (*state).clone(), actor, semantic_owner, candidate.clone()).is_ok()\n"
|
||||
" });\n"
|
||||
" let action = match farmhouse_choice\n .or_else(|| choose_action_with_session(state, semantic_owner, config, rng, session))\n {\n Some(action) => action,\n None => {\n tracing::warn!(player = ?actor, \"choose_action returned None"),
|
||||
])
|
||||
edit("crates/phase-ai/src/bin/ai_commander.rs", [("\nfn main() {\n", "\npub fn main() {\n")])
|
||||
|
||||
dest = os.path.join(root, "crates", "farmhouse-llm-seat")
|
||||
os.makedirs(os.path.join(dest, "src"), exist_ok=True)
|
||||
shutil.copy(os.path.join(crate, "Cargo.toml"), os.path.join(dest, "Cargo.toml"))
|
||||
shutil.copy(os.path.join(crate, "src", "main.rs"), os.path.join(dest, "src", "main.rs"))
|
||||
print("added crates/farmhouse-llm-seat")
|
||||
@@ -1,237 +0,0 @@
|
||||
//! ai-commander with model-driven seats (FarmHouse).
|
||||
//!
|
||||
//! phase's headless runner plays four-player Commander games with the built-in AI on every seat. This binary is that same runner (its
|
||||
//! source is included below, unchanged but for `fn main` being `pub`) plus a hook, installed before the games start, that asks a
|
||||
//! language model or a decision model (Laya / Kev, over the System One protocol) to choose for the seats named in `PHASE_LLM_SEATS`.
|
||||
//!
|
||||
//! The model never authors an action. For each decision the engine issues its own option list (`AiDecisionContract`), phase-llm renders
|
||||
//! it and builds the HTTP request, this file performs the HTTP call (the one thing phase-llm leaves to the host), and phase-llm turns the
|
||||
//! reply into one of the engine's options. The pick is then checked against the contract a second time here, and again by
|
||||
//! `run_ai_action`. ANY problem (forced move, timeout, an unreadable or stale answer, the call cap) returns `None` and phase's own AI
|
||||
//! plays the move, so a model that is down, slow or wrong cannot stall or corrupt a game.
|
||||
//!
|
||||
//! Settings (environment): PHASE_LLM_SEATS "1,3" (seats 0-3; empty = none, the plain runner), PHASE_LLM_PROVIDER (OpenAiCompatible | Jev),
|
||||
//! PHASE_LLM_BASE_URL, PHASE_LLM_MODEL, PHASE_LLM_API_KEY (leave empty for http:// endpoints), PHASE_LLM_MAX_TOKENS (256),
|
||||
//! PHASE_LLM_TEMPERATURE (0.2), PHASE_LLM_TIMEOUT_S (20), PHASE_LLM_MAX_CALLS (total model calls for the whole process, default 300),
|
||||
//! PHASE_LLM_DIFFICULTY (Medium: the strength hint in the prompt), PHASE_LLM_MAX_FAILS (4: model failures IN A ROW before every model seat
|
||||
//! switches itself off for the rest of the process).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use engine::ai_support::AiDecisionContract;
|
||||
use engine::database::CardDatabase;
|
||||
use engine::types::actions::GameAction;
|
||||
use engine::types::game_state::GameState;
|
||||
use engine::types::player::PlayerId;
|
||||
use phase_ai::auto_play::install_action_override;
|
||||
use phase_ai::config::AiDifficulty;
|
||||
use phase_llm::{LlmEndpointConfig, LlmProvider, LlmReply};
|
||||
|
||||
// The unchanged runner, with `fn main` made `pub` by apply_phase_patches.py.
|
||||
#[path = "../../phase-ai/src/bin/ai_commander.rs"]
|
||||
#[allow(dead_code, unused_imports, unused_variables)]
|
||||
mod commander;
|
||||
|
||||
struct Seats {
|
||||
seats: HashSet<u8>,
|
||||
endpoint: LlmEndpointConfig,
|
||||
difficulty: AiDifficulty,
|
||||
agent: ureq::Agent,
|
||||
cards_path: PathBuf,
|
||||
db: OnceLock<Option<CardDatabase>>,
|
||||
max_calls: u64,
|
||||
asked: [AtomicU64; 4],
|
||||
used: [AtomicU64; 4],
|
||||
fell_back: [AtomicU64; 4],
|
||||
total: AtomicU64,
|
||||
// Cost visibility and the circuit-breaker. A late-game prompt is thousands of tokens and a big-model server needs longer than the
|
||||
// per-call limit to read it; every such call used to burn the whole limit (a 9-minute game, 25 questions, 1 answer used). After
|
||||
// `max_fails` failures IN A ROW the model seats switch themselves off for the rest of the process and phase's AI plays on.
|
||||
timeouts: AtomicU64,
|
||||
consecutive_fails: AtomicU64,
|
||||
max_fails: u64,
|
||||
disabled: AtomicBool,
|
||||
millis: AtomicU64,
|
||||
answered: AtomicU64,
|
||||
prompt_chars: AtomicU64,
|
||||
}
|
||||
|
||||
impl Seats {
|
||||
fn database(&self) -> Option<&CardDatabase> {
|
||||
self.db
|
||||
.get_or_init(|| CardDatabase::from_export(&self.cards_path.join("card-data.json")).ok())
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// The model's pick for `actor`, or None to let phase's AI play. Never panics on a bad answer.
|
||||
/// One failed model call (timeout, error status, unusable answer). Returns None so callers can `return self.failed(..)`.
|
||||
fn failed(&self, timeout: bool) -> Option<GameAction> {
|
||||
if timeout {
|
||||
self.timeouts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let in_a_row = self.consecutive_fails.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if in_a_row >= self.max_fails && !self.disabled.swap(true, Ordering::Relaxed) {
|
||||
println!(
|
||||
"LLM-SEAT-DISABLED after {in_a_row} consecutive failures ({} timeouts of {} questions): phase's own AI plays the rest",
|
||||
self.timeouts.load(Ordering::Relaxed),
|
||||
self.total.load(Ordering::Relaxed)
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn decide(&self, state: &GameState, contract: &AiDecisionContract, actor: PlayerId) -> Option<GameAction> {
|
||||
if self.disabled.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let seat = (actor.0 as usize).min(3);
|
||||
// A forced move (one legal option) is never put to a model: build_game_decision_prompt refuses it.
|
||||
let request = phase_llm::build_game_decision_prompt(
|
||||
state,
|
||||
contract,
|
||||
self.difficulty,
|
||||
self.database(),
|
||||
&Vec::new(),
|
||||
)
|
||||
.ok()?;
|
||||
if self.total.fetch_add(1, Ordering::Relaxed) >= self.max_calls {
|
||||
return None; // the call cap: every later decision is phase's AI
|
||||
}
|
||||
self.asked[seat].fetch_add(1, Ordering::Relaxed);
|
||||
self.prompt_chars.fetch_add(request.prompt.char_count() as u64, Ordering::Relaxed);
|
||||
let http = phase_llm::build_chat_request(&self.endpoint, &request.prompt).ok()?;
|
||||
let mut call = self.agent.request(http.method, &http.url);
|
||||
for header in &http.headers {
|
||||
call = call.set(&header.name, &header.value);
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (status, body) = match call.send_string(&http.body) {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
match response.into_string() {
|
||||
Ok(text) => (status, text),
|
||||
Err(_) => return self.failed(true),
|
||||
}
|
||||
}
|
||||
// A 4xx/5xx still carries a body phase-llm knows how to classify.
|
||||
Err(ureq::Error::Status(code, response)) => (code, response.into_string().unwrap_or_default()),
|
||||
Err(_) => return self.failed(true), // transport error: a timeout, or the endpoint is down
|
||||
};
|
||||
self.millis.fetch_add(started.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
self.answered.fetch_add(1, Ordering::Relaxed);
|
||||
let selection = match phase_llm::select_action_from_response(
|
||||
state,
|
||||
contract,
|
||||
&request.fingerprint,
|
||||
LlmReply { provider: self.endpoint.provider, status, body: &body },
|
||||
) {
|
||||
Ok(selection) => selection,
|
||||
Err(_) => return self.failed(false),
|
||||
};
|
||||
if !contract.contains_action(state, &selection.action) {
|
||||
return self.failed(false);
|
||||
}
|
||||
self.consecutive_fails.store(0, Ordering::Relaxed);
|
||||
self.used[seat].fetch_add(1, Ordering::Relaxed);
|
||||
Some(selection.action)
|
||||
}
|
||||
|
||||
fn report(&self, seat: usize) {
|
||||
let asked = self.asked[seat].load(Ordering::Relaxed);
|
||||
if asked > 0 && asked % 25 == 0 {
|
||||
let answered = self.answered.load(Ordering::Relaxed).max(1);
|
||||
let questions = self.total.load(Ordering::Relaxed).max(1);
|
||||
println!(
|
||||
"LLM-SEAT seat={seat} asked={asked} used={} total_calls={} timeouts={} avg_ms={} avg_prompt_chars={}",
|
||||
self.used[seat].load(Ordering::Relaxed),
|
||||
self.total.load(Ordering::Relaxed),
|
||||
self.timeouts.load(Ordering::Relaxed),
|
||||
self.millis.load(Ordering::Relaxed) / answered,
|
||||
self.prompt_chars.load(Ordering::Relaxed) / questions
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or(name: &str, default: &str) -> String {
|
||||
std::env::var(name).ok().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn install_model_seats() {
|
||||
let seats: HashSet<u8> = env_or("PHASE_LLM_SEATS", "")
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<u8>().ok())
|
||||
.filter(|s| *s < 4)
|
||||
.collect();
|
||||
if seats.is_empty() {
|
||||
println!("LLM seats: none (plain ai-commander)");
|
||||
return;
|
||||
}
|
||||
let provider = LlmProvider::from_label(&env_or("PHASE_LLM_PROVIDER", "OpenAiCompatible"));
|
||||
let endpoint = LlmEndpointConfig {
|
||||
provider,
|
||||
base_url: std::env::var("PHASE_LLM_BASE_URL").ok().filter(|v| !v.trim().is_empty()),
|
||||
api_key: env_or("PHASE_LLM_API_KEY", ""),
|
||||
model: env_or("PHASE_LLM_MODEL", "model"),
|
||||
max_output_tokens: env_or("PHASE_LLM_MAX_TOKENS", "256").parse().ok(),
|
||||
temperature: env_or("PHASE_LLM_TEMPERATURE", "0.2").parse().ok(),
|
||||
};
|
||||
let timeout = env_or("PHASE_LLM_TIMEOUT_S", "20").parse::<u64>().unwrap_or(20);
|
||||
// The runner's first positional argument is the data folder (card-data.json lives there).
|
||||
let cards_path = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| ".".to_string()));
|
||||
let state = Seats {
|
||||
seats: seats.clone(),
|
||||
endpoint,
|
||||
difficulty: AiDifficulty::from_label(&env_or("PHASE_LLM_DIFFICULTY", "Medium")),
|
||||
agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(timeout)).redirects(0).build(),
|
||||
cards_path,
|
||||
db: OnceLock::new(),
|
||||
max_calls: env_or("PHASE_LLM_MAX_CALLS", "300").parse().unwrap_or(300),
|
||||
asked: Default::default(),
|
||||
used: Default::default(),
|
||||
fell_back: Default::default(),
|
||||
total: AtomicU64::new(0),
|
||||
timeouts: AtomicU64::new(0),
|
||||
consecutive_fails: AtomicU64::new(0),
|
||||
max_fails: env_or("PHASE_LLM_MAX_FAILS", "4").parse().unwrap_or(4),
|
||||
disabled: AtomicBool::new(false),
|
||||
millis: AtomicU64::new(0),
|
||||
answered: AtomicU64::new(0),
|
||||
prompt_chars: AtomicU64::new(0),
|
||||
};
|
||||
println!(
|
||||
"LLM seats: {:?} provider={} model={} max_calls={}",
|
||||
{
|
||||
let mut v: Vec<u8> = seats.iter().copied().collect();
|
||||
v.sort();
|
||||
v
|
||||
},
|
||||
provider.label(),
|
||||
state.endpoint.model,
|
||||
state.max_calls
|
||||
);
|
||||
let installed = install_action_override(Box::new(move |game, contract, _owner, actor| {
|
||||
if !state.seats.contains(&actor.0) {
|
||||
return None;
|
||||
}
|
||||
let seat = (actor.0 as usize).min(3);
|
||||
let pick = state.decide(game, contract, actor);
|
||||
if pick.is_none() {
|
||||
state.fell_back[seat].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
state.report(seat);
|
||||
pick
|
||||
}));
|
||||
if !installed {
|
||||
eprintln!("LLM seats: an action override was already installed; model seats are off");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
install_model_seats();
|
||||
commander::main();
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
"""phase-sim: run rules-accurate four-player Commander games with phase-rs's headless `ai-commander` and report what happened.
|
||||
|
||||
Standard library only. One job runs at a time (a game is CPU-heavy); more wait in a queue. The service never touches the network.
|
||||
|
||||
GET /health {"ok": true, "engine": ..., "queued": n, "running": id|null}
|
||||
POST /run {"decks": [4 x {"name", "commander": ["Card"], "main": [{"name","count"}, ...]}],
|
||||
"games": 1..MAX_GAMES, "seed": int, "difficulty": "Medium", "seat_difficulty": {"2": "Hard"}, "action_cap": int}
|
||||
-> {"job": id}
|
||||
GET /job/<id> status queued|running|done|error, games finished, and when done: per-game rows, wins by seat and by deck,
|
||||
win rate with a 95% interval, the cards the engine could not resolve, and the tail of the engine's output
|
||||
|
||||
Seat i is the i-th deck in the request (P0 = first). The engine treats every seat as AI; difficulty is per seat if given.
|
||||
A game that stalls, hits the action cap, or panics is reported as such and is NEVER counted as a win or a draw.
|
||||
|
||||
Model seats (optional, needs the image's ai-commander-llm binary): "llm_seats": [1, 3] and "llm": {"kind": "chat" | "decision", "model": "...",
|
||||
"max_calls": 300}. Those seats ask a model to pick among the ENGINE'S OWN legal options (phase-llm builds the prompt and checks the answer;
|
||||
anything wrong or slow falls back to phase's AI). kind "chat" goes to PHASE_LLM_CHAT_URL (an OpenAI-style endpoint, e.g. Olla) through this
|
||||
service's /llm relay, which switches the model's thinking off and caps its answer; kind "decision" goes to PHASE_LLM_DECISION_URL (a System One
|
||||
server: Laya or Kev) through /jev/systemone. Both relays accept connections from this machine only.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
BIN = os.environ.get("PHASE_AI_COMMANDER", "/opt/phase/ai-commander")
|
||||
CARD_DATA = os.environ.get("PHASE_CARD_DATA", "/opt/phase/data/card-data.json")
|
||||
ENGINE = os.environ.get("PHASE_ENGINE_VERSION", "phase-rs (version unknown)")
|
||||
MAX_GAMES = int(os.environ.get("PHASE_SIM_MAX_GAMES", "50"))
|
||||
JOB_TIMEOUT = int(os.environ.get("PHASE_SIM_TIMEOUT", "3600"))
|
||||
PORT = int(os.environ.get("PORT", "8090"))
|
||||
DIFFICULTIES = ("VeryEasy", "Easy", "Medium", "Hard", "VeryHard")
|
||||
BIN_LLM = os.environ.get("PHASE_AI_COMMANDER_LLM", "/opt/phase/ai-commander-llm")
|
||||
CHAT_URL = os.environ.get("PHASE_LLM_CHAT_URL", "").rstrip("/") # e.g. http://olla...:40114/olla/openai/v1
|
||||
DECISION_URL = os.environ.get("PHASE_LLM_DECISION_URL", "") # e.g. http://laya...:8080/v1/systemone
|
||||
DECISION_KEY = os.environ.get("PHASE_LLM_DECISION_KEY", "")
|
||||
CHAT_REASONING = os.environ.get("PHASE_LLM_REASONING", "none")
|
||||
CHAT_MAX_TOKENS = int(os.environ.get("PHASE_LLM_MAX_TOKENS_CAP", "256"))
|
||||
MAX_LLM_CALLS = int(os.environ.get("PHASE_LLM_MAX_CALLS", "600"))
|
||||
LOCAL_ONLY = ("127.0.0.1", "::1")
|
||||
# A late-game prompt is thousands of tokens; reading it takes a 27B model on a V100 20-30 s. The engine waits MODEL_TIMEOUT per question and the
|
||||
# relay a little less, and the engine switches the model seats off after PHASE_LLM_MAX_FAILS failures in a row, so a dead model costs a few minutes at most.
|
||||
RELAY_TIMEOUT = float(os.environ.get("PHASE_LLM_RELAY_TIMEOUT", "55"))
|
||||
MODEL_TIMEOUT = os.environ.get("PHASE_LLM_TIMEOUT_S", "60")
|
||||
MAX_DECK_ENTRIES = 400
|
||||
|
||||
_jobs = {}
|
||||
_queue = deque()
|
||||
_cv = threading.Condition()
|
||||
_running = {"id": None}
|
||||
|
||||
|
||||
# ---------- request -> engine inputs ------------------------------------------------------------------------------------------
|
||||
|
||||
class BadRequest(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def validate(req):
|
||||
decks = req.get("decks")
|
||||
if not isinstance(decks, list) or len(decks) != 4:
|
||||
raise BadRequest("decks must be a list of exactly 4 decks (one per seat)")
|
||||
feed = []
|
||||
for i, d in enumerate(decks):
|
||||
if not isinstance(d, dict) or not str(d.get("name") or "").strip():
|
||||
raise BadRequest("deck %d needs a name" % i)
|
||||
cmd = d.get("commander")
|
||||
if isinstance(cmd, str):
|
||||
cmd = [cmd]
|
||||
if not isinstance(cmd, list) or not cmd or not all(isinstance(c, str) and c.strip() for c in cmd):
|
||||
raise BadRequest("deck %d needs commander: [\"Card Name\"]" % i)
|
||||
main = d.get("main")
|
||||
if main is None and isinstance(d.get("cards"), list):
|
||||
counts = {}
|
||||
for n in d["cards"]:
|
||||
counts[str(n)] = counts.get(str(n), 0) + 1
|
||||
main = [{"name": k, "count": v} for k, v in counts.items()]
|
||||
if not isinstance(main, list) or not main or len(main) > MAX_DECK_ENTRIES:
|
||||
raise BadRequest("deck %d needs a non-empty main list (at most %d entries)" % (i, MAX_DECK_ENTRIES))
|
||||
rows = []
|
||||
for e in main:
|
||||
try:
|
||||
name, count = str(e["name"]), int(e["count"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise BadRequest("deck %d has a bad main entry: %r" % (i, e))
|
||||
if not name.strip() or not 1 <= count <= 99:
|
||||
raise BadRequest("deck %d has a bad main entry: %r" % (i, e))
|
||||
rows.append({"name": name, "count": count})
|
||||
feed.append({"name": str(d["name"]), "commander": [c.strip() for c in cmd], "main": rows})
|
||||
try:
|
||||
games = int(req.get("games", 1))
|
||||
seed = int(req.get("seed", 1))
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequest("games and seed must be integers")
|
||||
if not 1 <= games <= MAX_GAMES:
|
||||
raise BadRequest("games must be 1 to %d" % MAX_GAMES)
|
||||
difficulty = req.get("difficulty", "Medium")
|
||||
if difficulty not in DIFFICULTIES:
|
||||
raise BadRequest("difficulty must be one of %s" % ", ".join(DIFFICULTIES))
|
||||
seat_diff = {}
|
||||
for k, v in (req.get("seat_difficulty") or {}).items():
|
||||
if str(k) not in ("0", "1", "2", "3") or v not in DIFFICULTIES:
|
||||
raise BadRequest("seat_difficulty keys are seats 0-3, values one of %s" % ", ".join(DIFFICULTIES))
|
||||
seat_diff[int(k)] = v
|
||||
cap = req.get("action_cap")
|
||||
if cap is not None:
|
||||
try:
|
||||
cap = int(cap)
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequest("action_cap must be an integer")
|
||||
if not 100 <= cap <= 200000:
|
||||
raise BadRequest("action_cap must be 100 to 200000")
|
||||
llm = None
|
||||
seats = req.get("llm_seats") or []
|
||||
if seats:
|
||||
if not isinstance(seats, list) or not all(isinstance(x, int) and 0 <= x <= 3 for x in seats):
|
||||
raise BadRequest("llm_seats must be a list of seats 0-3")
|
||||
cfg = req.get("llm") or {}
|
||||
kind = cfg.get("kind", "chat")
|
||||
if kind not in ("chat", "decision"):
|
||||
raise BadRequest("llm.kind must be chat or decision")
|
||||
if (kind == "chat" and not CHAT_URL) or (kind == "decision" and not DECISION_URL):
|
||||
raise BadRequest("this service has no %s model endpoint configured" % kind)
|
||||
if not os.path.isfile(BIN_LLM) and not os.environ.get("PHASE_SIM_ALLOW_MISSING_LLM_BIN"):
|
||||
raise BadRequest("this image has no model-seat engine (ai-commander-llm)")
|
||||
model = str(cfg.get("model") or "").strip()
|
||||
if kind == "chat" and not model:
|
||||
raise BadRequest("llm.model is required for a chat model")
|
||||
try:
|
||||
max_calls = int(cfg.get("max_calls", 300))
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequest("llm.max_calls must be an integer")
|
||||
if not 1 <= max_calls <= MAX_LLM_CALLS:
|
||||
raise BadRequest("llm.max_calls must be 1 to %d" % MAX_LLM_CALLS)
|
||||
llm = {"seats": sorted(set(seats)), "kind": kind, "model": model or "decision-model", "max_calls": max_calls}
|
||||
return {"feed": feed, "games": games, "seed": seed, "difficulty": difficulty, "seat_difficulty": seat_diff, "action_cap": cap, "llm": llm}
|
||||
|
||||
|
||||
def engine_env(spec):
|
||||
"""The environment for the engine process: model-seat settings, or None (inherit) for the plain runner."""
|
||||
llm = spec.get("llm")
|
||||
if not llm:
|
||||
return None
|
||||
env = dict(os.environ)
|
||||
local = "http://127.0.0.1:%d" % PORT
|
||||
env.update(PHASE_LLM_SEATS=",".join(str(x) for x in llm["seats"]), PHASE_LLM_MODEL=llm["model"], PHASE_LLM_API_KEY="",
|
||||
PHASE_LLM_MAX_CALLS=str(llm["max_calls"]), PHASE_LLM_DIFFICULTY=spec["difficulty"], PHASE_LLM_TIMEOUT_S=MODEL_TIMEOUT)
|
||||
if llm["kind"] == "chat":
|
||||
env.update(PHASE_LLM_PROVIDER="OpenAiCompatible", PHASE_LLM_BASE_URL=local + "/llm/v1")
|
||||
else:
|
||||
env.update(PHASE_LLM_PROVIDER="Jev", PHASE_LLM_BASE_URL=local)
|
||||
return env
|
||||
|
||||
|
||||
def command(spec, root):
|
||||
cmd = [BIN_LLM if spec.get("llm") else BIN, root, "--feed", "feed.json", "--games-file", os.path.join(root, "games.txt")]
|
||||
for seat, level in sorted(spec["seat_difficulty"].items()):
|
||||
cmd += ["--difficulty-p%d" % seat, level]
|
||||
if spec["action_cap"]:
|
||||
cmd += ["--action-cap", str(spec["action_cap"])]
|
||||
return cmd
|
||||
|
||||
|
||||
# ---------- engine output -> results ------------------------------------------------------------------------------------------
|
||||
|
||||
GAME_HEAD = re.compile(r"^--- GAME\b(.*)$")
|
||||
WINNER = re.compile(r"Game ended cleanly\. Winner: (draw|P(\d))")
|
||||
TURNS = re.compile(r"^Turns played: (\d+)")
|
||||
ELIM = re.compile(r"^ELIMINATED: P(\d)\s+turn=(\d+)")
|
||||
RESOLVED = re.compile(r"^\s+P(\d)\s+main=\s*(\d+)\s+commander=(\d+)")
|
||||
LLM_STAT = re.compile(r"^LLM-SEAT seat=(\d) asked=(\d+) used=(\d+)(?: total_calls=(\d+))?(?: timeouts=(\d+))?(?: avg_ms=(\d+))?(?: avg_prompt_chars=(\d+))?")
|
||||
LLM_OFF = re.compile(r"^LLM-SEAT-DISABLED after (\d+) consecutive failures")
|
||||
SKIP = re.compile(r"^\s+SKIP (.+?): commander '(.+)' not in card db")
|
||||
PRE = re.compile(r"^\s+(.+?)\s+\|\s+commander: (.+?)\s+\|\s+main: (\d+) cards")
|
||||
|
||||
|
||||
def parse(output):
|
||||
"""Per-game rows and the deck-resolution facts from the engine's stdout. Games that did not finish are kept, labelled, never counted."""
|
||||
lines = output.splitlines()
|
||||
pre, resolved, skipped, games, cur = [], {}, [], [], None
|
||||
|
||||
def finish():
|
||||
if cur is not None:
|
||||
games.append(cur)
|
||||
|
||||
def new_game(label=""):
|
||||
return {"label": label, "status": "unfinished", "winner_seat": None, "turns": None, "eliminated": []}
|
||||
|
||||
result_line = re.compile(r"Game ended cleanly|^Turns played|^ELIMINATED:|^STALL:|ABORT: hit action cap|PANICKED")
|
||||
for ln in lines:
|
||||
if cur is None and not games: # the preamble: decks listed, then their resolved sizes
|
||||
m = PRE.match(ln)
|
||||
if m:
|
||||
pre.append({"deck": m.group(1), "commander": m.group(2), "listed": int(m.group(3))})
|
||||
continue
|
||||
m = SKIP.match(ln)
|
||||
if m:
|
||||
skipped.append({"deck": m.group(1), "commander": m.group(2)})
|
||||
continue
|
||||
m = RESOLVED.match(ln)
|
||||
if m and int(m.group(1)) not in resolved:
|
||||
resolved[int(m.group(1))] = {"main": int(m.group(2)), "commanders": int(m.group(3))}
|
||||
continue
|
||||
if GAME_HEAD.match(ln):
|
||||
finish()
|
||||
cur = new_game(GAME_HEAD.match(ln).group(1).strip(" -"))
|
||||
continue
|
||||
if cur is None:
|
||||
if not result_line.search(ln):
|
||||
continue # banner and preamble lines are not a game
|
||||
cur = new_game() # a single-game run prints no "--- GAME" header
|
||||
m = WINNER.search(ln)
|
||||
if m:
|
||||
cur["status"] = "draw" if m.group(1) == "draw" else "won"
|
||||
cur["winner_seat"] = None if m.group(2) is None else int(m.group(2))
|
||||
continue
|
||||
m = TURNS.match(ln)
|
||||
if m:
|
||||
cur["turns"] = int(m.group(1))
|
||||
continue
|
||||
m = ELIM.match(ln)
|
||||
if m:
|
||||
cur["eliminated"].append({"seat": int(m.group(1)), "turn": int(m.group(2))})
|
||||
continue
|
||||
if ln.startswith("STALL:"):
|
||||
cur["status"] = "stalled"
|
||||
cur.setdefault("detail", ln)
|
||||
elif "ABORT: hit action cap" in ln:
|
||||
cur["status"] = "action_cap"
|
||||
elif re.search(r"GAME .* PANICKED", ln):
|
||||
cur["status"] = "panicked"
|
||||
cur["detail"] = ln[:300]
|
||||
finish()
|
||||
games = [g for g in games if g["status"] != "unfinished" or g["turns"] is not None or g["eliminated"] or g["label"]]
|
||||
return {"pre": pre, "resolved": resolved, "skipped": skipped, "games": games}
|
||||
|
||||
|
||||
def llm_summary(llm, lines):
|
||||
"""The model seats' own counters (the engine prints one every 25th question), last value per seat, plus the breaker and the cost."""
|
||||
per_seat, extra, disabled = {}, {}, None
|
||||
for ln in lines:
|
||||
m = LLM_STAT.match(ln)
|
||||
if m:
|
||||
per_seat[int(m.group(1))] = {"asked": int(m.group(2)), "used": int(m.group(3))}
|
||||
for key, grp in (("timeouts", 5), ("avg_ms", 6), ("avg_prompt_chars", 7)):
|
||||
if m.group(grp) is not None:
|
||||
extra[key] = int(m.group(grp))
|
||||
continue
|
||||
m = LLM_OFF.match(ln)
|
||||
if m:
|
||||
disabled = int(m.group(1))
|
||||
out = {"seats": llm["seats"], "kind": llm["kind"], "model": llm["model"], "max_calls": llm["max_calls"], "per_seat": per_seat,
|
||||
"note": "counters are printed every 25th question, so the last few may be missing"}
|
||||
out.update(extra)
|
||||
if disabled is not None:
|
||||
out["switched_off_after_failures_in_a_row"] = disabled
|
||||
return out
|
||||
|
||||
|
||||
def wilson(wins, n, z=1.96):
|
||||
if n == 0:
|
||||
return [0.0, 0.0]
|
||||
p = wins / n
|
||||
d = 1 + z * z / n
|
||||
c = p + z * z / (2 * n)
|
||||
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
||||
return [round(max(0.0, (c - h) / d), 4), round(min(1.0, (c + h) / d), 4)]
|
||||
|
||||
|
||||
def summarize(spec, parsed, seeds):
|
||||
decks = [d["name"] for d in spec["feed"]]
|
||||
finished = [g for g in parsed["games"] if g["status"] in ("won", "draw")]
|
||||
won = [g for g in finished if g["status"] == "won"]
|
||||
by_seat = [sum(1 for g in won if g["winner_seat"] == i) for i in range(4)]
|
||||
n = len(finished)
|
||||
warnings = []
|
||||
for s in parsed["skipped"]:
|
||||
warnings.append("deck %r was skipped: commander %r is not in the engine's card database" % (s["deck"], s["commander"]))
|
||||
for i, pre in enumerate(parsed["pre"][:4]):
|
||||
got = (parsed["resolved"].get(i) or {}).get("main")
|
||||
if got is not None and got < pre["listed"]:
|
||||
warnings.append("seat %d (%s): %d of %d cards were not recognised by the engine and were left out" % (
|
||||
i, pre["deck"], pre["listed"] - got, pre["listed"]))
|
||||
bad = [g for g in parsed["games"] if g["status"] not in ("won", "draw")]
|
||||
if bad:
|
||||
warnings.append("%d game(s) did not finish (%s): not counted as wins or draws" % (
|
||||
len(bad), ", ".join(sorted({g["status"] for g in bad}))))
|
||||
for i, g in enumerate(parsed["games"]):
|
||||
if i < len(seeds):
|
||||
g["seed"] = seeds[i]
|
||||
return {"games_requested": spec["games"], "games_finished": n, "games_not_finished": len(bad), "draws": n - len(won),
|
||||
"wins_by_seat": by_seat,
|
||||
"decks": [{"seat": i, "name": decks[i], "wins": by_seat[i], "win_rate": round(by_seat[i] / n, 4) if n else None,
|
||||
"win_rate_95": wilson(by_seat[i], n)} for i in range(4)],
|
||||
"avg_turns": round(sum(g["turns"] or 0 for g in finished) / n, 1) if n else None,
|
||||
"warnings": warnings, "games": parsed["games"]}
|
||||
|
||||
|
||||
# ---------- jobs ----------------------------------------------------------------------------------------------------------------
|
||||
|
||||
def run_job(job):
|
||||
spec = job["spec"]
|
||||
root = tempfile.mkdtemp(prefix="phase-sim-")
|
||||
out_lines = []
|
||||
try:
|
||||
os.symlink(CARD_DATA, os.path.join(root, "card-data.json"))
|
||||
with open(os.path.join(root, "feed.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"decks": spec["feed"]}, f)
|
||||
seeds = [spec["seed"] + i for i in range(spec["games"])]
|
||||
with open(os.path.join(root, "games.txt"), "w", encoding="utf-8") as f:
|
||||
f.write("".join("%d,%s\n" % (s, spec["difficulty"]) for s in seeds))
|
||||
job["status"], job["started"] = "running", time.time()
|
||||
proc = subprocess.Popen(command(spec, root), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace", cwd=root,
|
||||
env=engine_env(spec))
|
||||
timer = threading.Timer(JOB_TIMEOUT, proc.kill)
|
||||
timer.start()
|
||||
try:
|
||||
for ln in proc.stdout:
|
||||
out_lines.append(ln.rstrip("\n"))
|
||||
if GAME_HEAD.match(ln):
|
||||
job["games_started"] += 1
|
||||
if WINNER.search(ln) or "GAME" in ln and "PANICKED" in ln or ln.startswith("STALL:") or "ABORT: hit action cap" in ln:
|
||||
job["games_done"] += 1
|
||||
code = proc.wait()
|
||||
finally:
|
||||
timer.cancel()
|
||||
text = "\n".join(out_lines)
|
||||
parsed = parse(text)
|
||||
job["result"] = summarize(spec, parsed, seeds)
|
||||
if spec.get("llm"):
|
||||
job["result"]["llm"] = llm_summary(spec["llm"], out_lines)
|
||||
if "switched_off_after_failures_in_a_row" in job["result"]["llm"]:
|
||||
job["result"]["warnings"].append(
|
||||
"the model seats switched themselves off after %d failures in a row (usually timeouts: late-game prompts are thousands of tokens "
|
||||
"and the model server could not read them in time); phase's own AI played the rest of the run, so the later moves are NOT the model's"
|
||||
% job["result"]["llm"]["switched_off_after_failures_in_a_row"])
|
||||
job["result"]["warnings"].append(
|
||||
"model seats %s: the model is asked about each non-forced decision, up to %d calls for the whole run; an answer the engine "
|
||||
"cannot use, a timeout, or any decision after the cap is played by phase's own AI instead" % (spec["llm"]["seats"], spec["llm"]["max_calls"]))
|
||||
job["engine_exit_code"] = code
|
||||
job["output_tail"] = out_lines[-40:]
|
||||
job["status"] = "done" if parsed["games"] else "error"
|
||||
if not parsed["games"]:
|
||||
job["error"] = "the engine produced no game results (exit code %s); see output_tail" % code
|
||||
except Exception as exc: # the job fails, the service does not
|
||||
job["status"], job["error"] = "error", "%s: %s" % (type(exc).__name__, exc)
|
||||
job["output_tail"] = out_lines[-40:]
|
||||
finally:
|
||||
job["finished"] = time.time()
|
||||
try:
|
||||
for name in os.listdir(root):
|
||||
os.unlink(os.path.join(root, name))
|
||||
os.rmdir(root)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def worker():
|
||||
while True:
|
||||
with _cv:
|
||||
while not _queue:
|
||||
_cv.wait()
|
||||
job = _queue.popleft()
|
||||
_running["id"] = job["id"]
|
||||
run_job(job)
|
||||
with _cv:
|
||||
_running["id"] = None
|
||||
|
||||
|
||||
def submit(req):
|
||||
spec = validate(req)
|
||||
job = {"id": uuid.uuid4().hex[:12], "status": "queued", "created": time.time(), "spec": spec, "games_started": 0, "games_done": 0}
|
||||
with _cv:
|
||||
_jobs[job["id"]] = job
|
||||
_queue.append(job)
|
||||
for old in sorted(_jobs.values(), key=lambda j: j["created"])[:-50]: # remember the last 50
|
||||
if old["status"] in ("done", "error"):
|
||||
_jobs.pop(old["id"], None)
|
||||
_cv.notify()
|
||||
return job["id"]
|
||||
|
||||
|
||||
def public(job):
|
||||
out = {k: job[k] for k in ("id", "status", "games_started", "games_done") if k in job}
|
||||
out["games_requested"] = job["spec"]["games"]
|
||||
for k in ("result", "error", "output_tail", "engine_exit_code"):
|
||||
if k in job:
|
||||
out[k] = job[k]
|
||||
if "started" in job:
|
||||
out["seconds"] = round((job.get("finished") or time.time()) - job["started"], 1)
|
||||
return out
|
||||
|
||||
|
||||
def forward(url, data, headers):
|
||||
req = urllib.request.Request(url, data=data, headers=dict({"content-type": "application/json"}, **headers))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=RELAY_TIMEOUT) as r:
|
||||
return r.status, json.loads(r.read() or b"{}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
return exc.code, json.loads(exc.read() or b"{}")
|
||||
except ValueError:
|
||||
return exc.code, {"error": "upstream error"}
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return 502, {"error": "the model endpoint is not reachable"}
|
||||
|
||||
|
||||
def relay_chat(body, remote):
|
||||
"""POST /llm/v1/chat/completions -> the OpenAI-style endpoint, with the model's thinking off and its answer capped."""
|
||||
if remote not in LOCAL_ONLY or not CHAT_URL:
|
||||
return 403, {"error": "not available"}
|
||||
try:
|
||||
req = json.loads(body)
|
||||
req["max_tokens"] = min(int(req.get("max_tokens") or CHAT_MAX_TOKENS), CHAT_MAX_TOKENS)
|
||||
req["stream"] = False
|
||||
if CHAT_REASONING:
|
||||
req["reasoning_effort"] = CHAT_REASONING
|
||||
data = json.dumps(req).encode()
|
||||
except (ValueError, TypeError):
|
||||
return 400, {"error": "bad chat request"}
|
||||
return forward(CHAT_URL + "/chat/completions", data, {})
|
||||
|
||||
|
||||
def relay_decision(body, remote):
|
||||
"""POST /jev/systemone: phase's Jev envelope {"apiKey", "request": {...}} -> a System One server (Laya / Kev); the key comes from the environment."""
|
||||
if remote not in LOCAL_ONLY or not DECISION_URL:
|
||||
return 403, {"error": "not available"}
|
||||
try:
|
||||
data = json.dumps(json.loads(body)["request"]).encode()
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return 400, {"error": "bad decision request"}
|
||||
return forward(DECISION_URL, data, {"authorization": "Bearer " + DECISION_KEY} if DECISION_KEY else {})
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "phase-sim/1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
if os.environ.get("PHASE_SIM_LOG") == "1":
|
||||
sys.stderr.write("%s\n" % (fmt % args))
|
||||
|
||||
def _send(self, code, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
with _cv:
|
||||
return self._send(200, {"ok": os.path.isfile(BIN) and os.path.isfile(CARD_DATA), "engine": ENGINE,
|
||||
"queued": len(_queue), "running": _running["id"], "max_games": MAX_GAMES})
|
||||
m = re.match(r"^/job/([0-9a-f]+)$", self.path)
|
||||
if m:
|
||||
with _cv:
|
||||
job = _jobs.get(m.group(1))
|
||||
return self._send(200, public(job)) if job else self._send(404, {"error": "no such job"})
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path in ("/llm/v1/chat/completions", "/jev/systemone"):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n > 4_000_000:
|
||||
return self._send(413, {"error": "request too large"})
|
||||
fn = relay_chat if self.path.startswith("/llm") else relay_decision
|
||||
code, obj = fn(self.rfile.read(n), self.client_address[0])
|
||||
return self._send(code, obj)
|
||||
if self.path != "/run":
|
||||
return self._send(404, {"error": "not found"})
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
if n > 2_000_000:
|
||||
return self._send(413, {"error": "request too large"})
|
||||
req = json.loads(self.rfile.read(n) or b"{}")
|
||||
if not isinstance(req, dict):
|
||||
raise BadRequest("the body must be a JSON object")
|
||||
self._send(202, {"job": submit(req)})
|
||||
except (BadRequest, json.JSONDecodeError) as exc:
|
||||
self._send(400, {"error": str(exc)})
|
||||
|
||||
|
||||
def main():
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,108 +0,0 @@
|
||||
"""Smoke test, run INSIDE the image (CI and by hand): four real decks from phase's own sample feed play real games through the service.
|
||||
|
||||
python /opt/phase/phase_smoke.py [games] [difficulty] plain engine
|
||||
python /opt/phase/phase_smoke.py --llm model seats against a stub model inside this process
|
||||
|
||||
Exits non-zero unless at least one game finished with a winner or a draw. Prints what the engine reported, including any cards it did
|
||||
not recognise, so a bad data/engine pairing shows up here and not in a result someone trusts.
|
||||
|
||||
--llm starts a stub OpenAI-style model on 127.0.0.1:9099 (the service must have been started with PHASE_LLM_CHAT_URL=http://127.0.0.1:9099/v1),
|
||||
puts seats 1 and 3 on it, and passes when the engine's own counters show the model was asked AND its answers were used by the engine: that
|
||||
proves the Rust hook, the relay and phase's answer check end to end without any outside service. The stub always answers option 0, so the
|
||||
game itself may not finish (it can hit the action cap); that is not what is being tested.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
BASE = "http://127.0.0.1:8090"
|
||||
LLM = "--llm" in sys.argv
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
games = int(args[0]) if args else (1 if LLM else 2)
|
||||
difficulty = args[1] if len(args) > 1 else "VeryEasy"
|
||||
|
||||
ASKED = []
|
||||
|
||||
|
||||
class StubModel(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
|
||||
ASKED.append(body)
|
||||
out = json.dumps({"choices": [{"index": 0, "message": {"role": "assistant", "content": "{\"choice\": 0, \"reason\": \"stub\"}"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(out)))
|
||||
self.end_headers()
|
||||
self.wfile.write(out)
|
||||
|
||||
|
||||
if LLM:
|
||||
threading.Thread(target=HTTPServer(("127.0.0.1", 9099), StubModel).serve_forever, daemon=True).start()
|
||||
|
||||
|
||||
def call(path, body=None):
|
||||
req = urllib.request.Request(BASE + path, data=None if body is None else json.dumps(body).encode(), headers={"content-type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
for _ in range(60):
|
||||
try:
|
||||
h = call("/health")
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(1)
|
||||
else:
|
||||
sys.exit("the service did not come up")
|
||||
print("health:", h)
|
||||
assert h["ok"], "binary or card data missing"
|
||||
|
||||
feed = json.load(open("/opt/phase/sample-feed.json", encoding="utf-8"))["decks"]
|
||||
decks = []
|
||||
for d in feed:
|
||||
cmd = d.get("commander") or [d["name"]]
|
||||
decks.append({"name": d["name"], "commander": cmd, "main": d["main"]})
|
||||
if len(decks) == 4:
|
||||
break
|
||||
request = {"decks": decks, "games": games, "seed": 7, "difficulty": difficulty, "action_cap": 60000}
|
||||
if LLM:
|
||||
request.update(action_cap=6000, llm_seats=[1, 3], llm={"kind": "chat", "model": "stub-model", "max_calls": 60})
|
||||
job = call("/run", request)["job"]
|
||||
t0 = time.time()
|
||||
while True:
|
||||
j = call("/job/" + job)
|
||||
if j["status"] in ("done", "error"):
|
||||
break
|
||||
if time.time() - t0 > 1500:
|
||||
sys.exit("timed out; last: %s" % json.dumps(j)[:300])
|
||||
time.sleep(3)
|
||||
print("job: %s in %.0f s" % (j["status"], time.time() - t0))
|
||||
res = j.get("result") or {}
|
||||
print(json.dumps({k: res.get(k) for k in ("games_requested", "games_finished", "games_not_finished", "draws", "wins_by_seat", "avg_turns", "warnings")}, indent=1))
|
||||
for g in res.get("games", []):
|
||||
print(" game seed=%s %s winner=%s turns=%s eliminated=%s" % (g.get("seed"), g["status"], g.get("winner_seat"), g.get("turns"), g.get("eliminated")))
|
||||
if LLM:
|
||||
llm = res.get("llm") or {}
|
||||
print("model seats:", json.dumps(llm), "| requests the stub model received:", len(ASKED))
|
||||
print("\n".join((j.get("output_tail") or [])[-15:]))
|
||||
seats = llm.get("per_seat") or {}
|
||||
asked = sum(v["asked"] for v in seats.values())
|
||||
used = sum(v["used"] for v in seats.values())
|
||||
reasoned = [a for a in ASKED if a.get("reasoning_effort") == "none"]
|
||||
if not ASKED or not asked or not used:
|
||||
sys.exit("the model seats were not exercised: stub got %d requests, engine counters asked=%d used=%d" % (len(ASKED), asked, used))
|
||||
if len(reasoned) != len(ASKED):
|
||||
sys.exit("the relay did not switch the model's thinking off on every request")
|
||||
print("PHASE LLM SMOKE OK (asked %d, engine used %d, stub saw %d requests, all with thinking off)" % (asked, used, len(ASKED)))
|
||||
sys.exit(0)
|
||||
if j["status"] != "done" or res.get("games_finished", 0) < 1:
|
||||
print("\n".join(j.get("output_tail") or []))
|
||||
sys.exit("no game finished")
|
||||
print("PHASE SMOKE OK")
|
||||
@@ -1,272 +0,0 @@
|
||||
"""Run: python -I -X utf8 test_phase_sim.py (offline: a stub engine prints output in the format phase's ai-commander prints)"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import phase_sim as P # noqa: E402
|
||||
|
||||
FAILS = 0
|
||||
|
||||
|
||||
def ck(name, cond, detail=""):
|
||||
global FAILS
|
||||
print(" %s %s%s" % ("ok " if cond else "FAIL", name, (" -- " + str(detail)[:260]) if detail and not cond else ""))
|
||||
if not cond:
|
||||
FAILS += 1
|
||||
|
||||
|
||||
def deck(name, cmd, n=3):
|
||||
return {"name": name, "commander": [cmd], "main": [{"name": "Sol Ring", "count": 1}, {"name": "Forest", "count": n}]}
|
||||
|
||||
|
||||
GOOD = {"decks": [deck("A", "Cmd A"), deck("B", "Cmd B"), deck("C", "Cmd C"), deck("D", "Cmd D")], "games": 3, "seed": 10}
|
||||
|
||||
# --- validation
|
||||
def bad(mut, why):
|
||||
req = json.loads(json.dumps(GOOD))
|
||||
mut(req)
|
||||
try:
|
||||
P.validate(req)
|
||||
ck("rejects " + why, False)
|
||||
except P.BadRequest as exc:
|
||||
ck("rejects " + why, True, exc)
|
||||
|
||||
ck("a good request validates", P.validate(GOOD)["games"] == 3)
|
||||
bad(lambda r: r["decks"].pop(), "three decks")
|
||||
bad(lambda r: r["decks"][0].pop("commander"), "a deck with no commander")
|
||||
bad(lambda r: r["decks"][1].update(main=[]), "an empty main list")
|
||||
bad(lambda r: r["decks"][2]["main"].append({"name": "X", "count": 0}), "a zero count")
|
||||
bad(lambda r: r.update(games=P.MAX_GAMES + 1), "too many games")
|
||||
bad(lambda r: r.update(difficulty="Impossible"), "an unknown difficulty")
|
||||
bad(lambda r: r.update(seat_difficulty={"7": "Easy"}), "a seat outside 0-3")
|
||||
bad(lambda r: r.update(action_cap=5), "a tiny action cap")
|
||||
v = P.validate(dict(GOOD, decks=[dict(deck("A", "Cmd A"), commander="Cmd A", main=None, cards=["Forest", "Forest", "Sol Ring"])] + GOOD["decks"][1:]))
|
||||
ck("a plain card list becomes counted rows and a commander string becomes a list",
|
||||
v["feed"][0]["commander"] == ["Cmd A"] and {"name": "Forest", "count": 2} in v["feed"][0]["main"], v["feed"][0])
|
||||
|
||||
# --- parsing the engine's output
|
||||
OUT = """=== 4-player Commander AI test ===
|
||||
Feed: feed.json
|
||||
Batch mode: 5 games from games.txt
|
||||
|
||||
A | commander: Cmd A | main: 99 cards
|
||||
B | commander: Cmd B | main: 99 cards
|
||||
SKIP C: commander 'Cmd C' not in card db
|
||||
D | commander: Cmd D | main: 99 cards
|
||||
Resolved deck sizes (post name-resolution, 0-indexed by seat):
|
||||
P0 main= 99 commander=1
|
||||
P1 main= 97 commander=1
|
||||
P2 main= 99 commander=1
|
||||
P3 main= 99 commander=1
|
||||
|
||||
ExecutionMode: measurement
|
||||
--- GAME seed=10 difficulty=Medium ---
|
||||
Game started. 4 players.
|
||||
ELIMINATED: P1 turn=9 actions= 400 elapsed=3.1s
|
||||
ELIMINATED: P3 turn=12 actions= 600 elapsed=5.0s
|
||||
|
||||
=== RESULT ===
|
||||
Elapsed: 8.0s
|
||||
Turns played: 14
|
||||
|
||||
Game ended cleanly. Winner: P2
|
||||
--- GAME seed=11 difficulty=Medium ---
|
||||
Game started. 4 players.
|
||||
=== RESULT ===
|
||||
Turns played: 40
|
||||
Game ended cleanly. Winner: draw
|
||||
--- GAME seed=12 difficulty=Medium ---
|
||||
=== RESULT ===
|
||||
Turns played: 7
|
||||
STALL: waiting_for=ChooseTarget turn=7 active=P0 priority=P0 pending_cast=none
|
||||
Game did NOT reach GameOver. waiting_for = ChooseTarget
|
||||
--- GAME seed=13 difficulty=Medium ---
|
||||
ABORT: hit action cap=50000
|
||||
=== RESULT ===
|
||||
Turns played: 31
|
||||
Game did NOT reach GameOver. waiting_for = Priority
|
||||
--- GAME seed=14 difficulty=Medium ---
|
||||
GAME 14 PANICKED: index out of bounds
|
||||
"""
|
||||
parsed = P.parse(OUT)
|
||||
st = [g["status"] for g in parsed["games"]]
|
||||
ck("five games found with their outcomes in order", st == ["won", "draw", "stalled", "action_cap", "panicked"], st)
|
||||
g0 = parsed["games"][0]
|
||||
ck("winner seat, turns and elimination order are read", g0["winner_seat"] == 2 and g0["turns"] == 14
|
||||
and [e["seat"] for e in g0["eliminated"]] == [1, 3], g0)
|
||||
ck("a draw has no winner seat", parsed["games"][1]["winner_seat"] is None)
|
||||
ck("pre-resolution and resolved deck sizes and the skipped deck are read", len(parsed["pre"]) == 3 and parsed["resolved"][1]["main"] == 97
|
||||
and parsed["skipped"] == [{"deck": "C", "commander": "Cmd C"}], (parsed["pre"], parsed["resolved"], parsed["skipped"]))
|
||||
|
||||
spec = P.validate(dict(GOOD, games=5))
|
||||
res = P.summarize(spec, parsed, [10, 11, 12, 13, 14])
|
||||
ck("only finished games are counted", res["games_finished"] == 2 and res["games_not_finished"] == 3 and res["draws"] == 1, res)
|
||||
ck("wins by seat and per-deck win rate", res["wins_by_seat"] == [0, 0, 1, 0] and res["decks"][2]["win_rate"] == 0.5 and res["decks"][0]["win_rate"] == 0.0)
|
||||
lo, hi = res["decks"][2]["win_rate_95"]
|
||||
ck("a 95% interval wraps the rate and stays in 0..1", 0 <= lo < 0.5 < hi <= 1)
|
||||
w = " | ".join(res["warnings"])
|
||||
ck("warnings: a skipped deck, unrecognised cards, and unfinished games (never counted)",
|
||||
"was skipped" in w and "2 of 99" in w and "not counted as wins or draws" in w and "stalled" in w, w)
|
||||
ck("each game carries its seed", [g["seed"] for g in res["games"]] == [10, 11, 12, 13, 14])
|
||||
ck("interval of nothing is zeros, of a sweep is high", P.wilson(0, 0) == [0.0, 0.0] and P.wilson(10, 10)[0] > 0.7)
|
||||
|
||||
# --- a whole job through the real worker path with a stub engine
|
||||
STUB = os.path.join(tempfile.mkdtemp(), "stub_engine.py")
|
||||
with open(STUB, "w", encoding="utf-8") as f:
|
||||
f.write('''import sys, os
|
||||
root = sys.argv[1]
|
||||
seeds = [l.split(",")[0] for l in open(os.path.join(root, "games.txt")) if l.strip()]
|
||||
feed = __import__("json").load(open(os.path.join(root, "feed.json")))
|
||||
assert os.path.exists(os.path.join(root, "card-data.json")) and len(feed["decks"]) == 4
|
||||
for d in feed["decks"]:
|
||||
print(" %s | commander: %s | main: %d cards" % (d["name"], d["commander"][0], sum(e["count"] for e in d["main"])))
|
||||
print("Resolved deck sizes (post name-resolution, 0-indexed by seat):")
|
||||
for i, d in enumerate(feed["decks"]):
|
||||
print(" P%d main=%3d commander=1" % (i, sum(e["count"] for e in d["main"])))
|
||||
for k, s in enumerate(seeds):
|
||||
print("--- GAME seed=%s ---" % s)
|
||||
print("Turns played: %d" % (10 + k))
|
||||
print("Game ended cleanly. Winner: P%d" % (k % 4))
|
||||
''')
|
||||
CD = os.path.join(os.path.dirname(STUB), "card-data.json")
|
||||
open(CD, "w").write("{}")
|
||||
P.CARD_DATA = CD
|
||||
ORIG_COMMAND = P.command
|
||||
P.command = lambda spec, root: [sys.executable, STUB, root]
|
||||
job_id = P.submit(dict(GOOD, games=4))
|
||||
threading.Thread(target=P.worker, daemon=True).start()
|
||||
for _ in range(100):
|
||||
j = P._jobs[job_id]
|
||||
if j["status"] in ("done", "error"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
pub = P.public(P._jobs[job_id])
|
||||
ck("a queued job runs to done with four finished games, one win each", pub["status"] == "done" and pub["result"]["games_finished"] == 4
|
||||
and pub["result"]["wins_by_seat"] == [1, 1, 1, 1] and pub["games_done"] == 4, pub)
|
||||
ck("no warnings when every card resolved", pub["result"]["warnings"] == [], pub["result"]["warnings"])
|
||||
ck("the temp folder is cleaned up", not [d for d in os.listdir(tempfile.gettempdir()) if d.startswith("phase-sim-")])
|
||||
|
||||
# --- the HTTP surface
|
||||
srv = P.ThreadingHTTPServer(("127.0.0.1", 0), P.Handler)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
base = "http://127.0.0.1:%d" % srv.server_port
|
||||
def call(path, body=None):
|
||||
req = urllib.request.Request(base + path, data=None if body is None else json.dumps(body).encode(), headers={"content-type": "application/json"})
|
||||
try:
|
||||
r = urllib.request.urlopen(req, timeout=10)
|
||||
return r.status, json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.load(e)
|
||||
code, h = call("/health")
|
||||
ck("/health answers (ok is false only because the real binary is not here)", code == 200 and "engine" in h and h["max_games"] == P.MAX_GAMES, h)
|
||||
code, r = call("/run", dict(GOOD, games=1))
|
||||
ck("POST /run is accepted with a job id", code == 202 and len(r["job"]) == 12, r)
|
||||
for _ in range(100):
|
||||
code, j = call("/job/" + r["job"])
|
||||
if j["status"] in ("done", "error"):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
ck("GET /job/<id> returns the result", code == 200 and j["status"] == "done" and j["result"]["games_finished"] == 1, j)
|
||||
ck("a bad request is a 400 with a reason", call("/run", {"decks": []})[0] == 400)
|
||||
ck("an unknown job is a 404", call("/job/deadbeef")[0] == 404)
|
||||
ck("an unknown path is a 404", call("/nope")[0] == 404)
|
||||
# --- model seats: validation, environment, relays
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
UP = []
|
||||
|
||||
|
||||
class Upstream(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
|
||||
UP.append((self.path, body, self.headers.get("authorization")))
|
||||
out = json.dumps({"echo": True, "path": self.path}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(out)))
|
||||
self.end_headers()
|
||||
self.wfile.write(out)
|
||||
|
||||
|
||||
up = HTTPServer(("127.0.0.1", 0), Upstream)
|
||||
threading.Thread(target=up.serve_forever, daemon=True).start()
|
||||
UPBASE = "http://127.0.0.1:%d" % up.server_port
|
||||
os.environ["PHASE_SIM_ALLOW_MISSING_LLM_BIN"] = "1"
|
||||
LLMREQ = dict(GOOD, llm_seats=[1, 3], llm={"kind": "chat", "model": "twin-turbo", "max_calls": 120})
|
||||
|
||||
P.CHAT_URL, P.DECISION_URL = "", ""
|
||||
try:
|
||||
P.validate(LLMREQ)
|
||||
ck("model seats are refused when no model endpoint is configured", False)
|
||||
except P.BadRequest as exc:
|
||||
ck("model seats are refused when no model endpoint is configured", "no chat model endpoint" in str(exc), exc)
|
||||
P.CHAT_URL, P.DECISION_URL, P.DECISION_KEY = UPBASE + "/v1", UPBASE + "/v1/systemone", "sekret"
|
||||
v = P.validate(LLMREQ)
|
||||
ck("a chat model seat request validates", v["llm"] == {"seats": [1, 3], "kind": "chat", "model": "twin-turbo", "max_calls": 120}, v["llm"])
|
||||
ck("a request with no model seats has no llm settings", P.validate(GOOD)["llm"] is None)
|
||||
for mut, why in ((lambda r: r.update(llm_seats=[4]), "a seat outside 0-3"),
|
||||
(lambda r: r["llm"].pop("model"), "a chat model with no name"),
|
||||
(lambda r: r["llm"].update(max_calls=P.MAX_LLM_CALLS + 1), "too many model calls"),
|
||||
(lambda r: r["llm"].update(kind="telepathy"), "an unknown kind")):
|
||||
req = json.loads(json.dumps(LLMREQ))
|
||||
mut(req)
|
||||
try:
|
||||
P.validate(req)
|
||||
ck("rejects " + why, False)
|
||||
except P.BadRequest:
|
||||
ck("rejects " + why, True)
|
||||
dv = P.validate(dict(GOOD, llm_seats=[0], llm={"kind": "decision"}))
|
||||
ck("a decision-model seat needs no model name", dv["llm"]["kind"] == "decision" and dv["llm"]["model"] == "decision-model")
|
||||
|
||||
env = P.engine_env(v)
|
||||
ck("chat seats: the engine is pointed at this service's /llm relay as an OpenAI-compatible endpoint with no key",
|
||||
env["PHASE_LLM_SEATS"] == "1,3" and env["PHASE_LLM_PROVIDER"] == "OpenAiCompatible" and env["PHASE_LLM_BASE_URL"].endswith("/llm/v1")
|
||||
and env["PHASE_LLM_MODEL"] == "twin-turbo" and env["PHASE_LLM_API_KEY"] == "" and env["PHASE_LLM_MAX_CALLS"] == "120", env)
|
||||
denv = P.engine_env(dv)
|
||||
ck("decision seats: provider Jev, base is this service (the engine adds /jev/systemone)", denv["PHASE_LLM_PROVIDER"] == "Jev"
|
||||
and denv["PHASE_LLM_BASE_URL"] == "http://127.0.0.1:%d" % P.PORT)
|
||||
ck("no model seats: no special environment, the plain engine", P.engine_env(P.validate(GOOD)) is None
|
||||
and ORIG_COMMAND(P.validate(GOOD), "/r")[0] == P.BIN and ORIG_COMMAND(v, "/r")[0] == P.BIN_LLM)
|
||||
|
||||
code, out = P.relay_chat(json.dumps({"model": "twin-turbo", "messages": [{"role": "user", "content": "x"}], "max_tokens": 4000, "stream": True}).encode(), "127.0.0.1")
|
||||
path, body, auth = UP[-1]
|
||||
ck("the chat relay forwards to /chat/completions with thinking off, the answer capped and streaming off",
|
||||
code == 200 and path == "/v1/chat/completions" and body["reasoning_effort"] == "none" and body["max_tokens"] == P.CHAT_MAX_TOKENS
|
||||
and body["stream"] is False and body["model"] == "twin-turbo", (code, path, body))
|
||||
ck("a request without max_tokens gets the cap", (P.relay_chat(b'{"model": "m", "messages": []}', "127.0.0.1"), UP[-1][1]["max_tokens"] == P.CHAT_MAX_TOKENS)[1])
|
||||
ck("the relays refuse anyone but this machine", P.relay_chat(b"{}", "10.1.2.3")[0] == 403 and P.relay_decision(b"{}", "10.1.2.3")[0] == 403)
|
||||
ck("a malformed chat body is a 400, not an exception", P.relay_chat(b"not json", "127.0.0.1")[0] == 400)
|
||||
envelope = {"apiKey": "ignored-by-us", "request": {"state": {"body": "t"}, "questions": {"pick": {"type": "choice"}}}}
|
||||
code, out = P.relay_decision(json.dumps(envelope).encode(), "127.0.0.1")
|
||||
path, body, auth = UP[-1]
|
||||
ck("the decision relay unwraps phase's envelope and sends only the request, with OUR key", code == 200 and path == "/v1/systemone"
|
||||
and body == envelope["request"] and auth == "Bearer sekret", (code, path, body, auth))
|
||||
ck("a decision body with no request is a 400", P.relay_decision(b'{"apiKey": "k"}', "127.0.0.1")[0] == 400)
|
||||
P.CHAT_URL = "http://127.0.0.1:1/v1"
|
||||
ck("an unreachable model endpoint is a 502", P.relay_chat(b'{"model": "m", "messages": []}', "127.0.0.1")[0] == 502)
|
||||
P.CHAT_URL = UPBASE + "/v1"
|
||||
|
||||
summ = P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
|
||||
["LLM-SEAT seat=1 asked=25 used=20 total_calls=25", "noise", "LLM-SEAT seat=1 asked=50 used=41 total_calls=50"])
|
||||
ck("the model seats' counters are read from the engine's output (last value wins)", summ["per_seat"] == {1: {"asked": 50, "used": 41}}, summ)
|
||||
|
||||
summ2 = P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
|
||||
["LLM-SEAT seat=1 asked=25 used=1 total_calls=25 timeouts=20 avg_ms=1800 avg_prompt_chars=31000",
|
||||
"LLM-SEAT-DISABLED after 4 consecutive failures (20 timeouts of 25 questions): phase's own AI plays the rest"])
|
||||
ck("cost counters and the circuit-breaker are read from the engine's output", summ2["timeouts"] == 20 and summ2["avg_ms"] == 1800
|
||||
and summ2["avg_prompt_chars"] == 31000 and summ2["switched_off_after_failures_in_a_row"] == 4 and summ2["per_seat"] == {1: {"asked": 25, "used": 1}}, summ2)
|
||||
ck("old three-field counter lines still parse", P.llm_summary({"seats": [1], "kind": "chat", "model": "m", "max_calls": 9},
|
||||
["LLM-SEAT seat=2 asked=25 used=24 total_calls=25"])["per_seat"] == {2: {"asked": 25, "used": 24}})
|
||||
|
||||
code, r = call("/llm/v1/chat/completions", {"model": "m", "messages": []})
|
||||
ck("over HTTP from this machine the chat relay answers", code == 200 and r["echo"] is True, (code, r))
|
||||
print("\nFAILURES: %d" % FAILS)
|
||||
sys.exit(1 if FAILS else 0)
|
||||
@@ -1,42 +0,0 @@
|
||||
with open('ml/eval/forge_evaluator.py', 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# Fix 1: absolute JAR path in subprocess call
|
||||
data = data.replace(
|
||||
b'"--forge-jar", self.cfg.forge_jar,',
|
||||
b'"--forge-jar", str(Path(self.cfg.forge_jar).resolve()),'
|
||||
)
|
||||
|
||||
# Fix 2: resolve forge_work_dir to absolute path too
|
||||
data = data.replace(
|
||||
b'"--forge-dir", self.cfg.forge_work_dir,',
|
||||
b'"--forge-dir", str(Path(self.cfg.forge_work_dir).resolve()),'
|
||||
)
|
||||
|
||||
# Fix 3: change default forge_work_dir
|
||||
data = data.replace(
|
||||
b'forge_work_dir: str = "forge"',
|
||||
b'forge_work_dir: str = "D:/ForgeCommander/forge-repo/forge-gui"'
|
||||
)
|
||||
|
||||
with open('ml/eval/forge_evaluator.py', 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
import py_compile
|
||||
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
|
||||
print('forge_evaluator.py - Syntax OK')
|
||||
|
||||
# Fix eval_policy.py too
|
||||
with open('scripts/eval_policy.py', 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
data = data.replace(
|
||||
b'"--forge-dir", default="forge"',
|
||||
b'"--forge-dir", default="D:/ForgeCommander/forge-repo/forge-gui"'
|
||||
)
|
||||
|
||||
with open('scripts/eval_policy.py', 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
py_compile.compile('scripts/eval_policy.py', doraise=True)
|
||||
print('eval_policy.py - Syntax OK')
|
||||
+38
-70
@@ -38,12 +38,13 @@ from routes.lab import router as lab_router
|
||||
from routes.scanner import router as scanner_router
|
||||
from routes.rag import router as rag_router
|
||||
from routes.deepseek import router as deepseek_router
|
||||
from routes.farmhouse_phase import router as farmhouse_phase_router # FARMHOUSE_PHASE_IMPORT
|
||||
from routes.deckgen import router as deckgen_router
|
||||
from routes.coach import router as coach_router, init_coach_service
|
||||
from routes.ml import router as ml_router
|
||||
from routes.ws_game import router as ws_game_router
|
||||
from routes.game import router as game_router
|
||||
# Phase 0: card art endpoints
|
||||
from routes.card_art import router as card_art_router
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
@@ -51,11 +52,8 @@ _DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost
|
||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
# Module-level handle so lifespan can call .load() after routes are registered.
|
||||
_POLICY_SVC = None
|
||||
|
||||
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ──────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def _lifespan(application: FastAPI):
|
||||
# ──── STARTUP ────
|
||||
@@ -63,7 +61,6 @@ async def _lifespan(application: FastAPI):
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
init_collection_db()
|
||||
validate_forge_config()
|
||||
init_coach_service() # FARMHOUSE_COACH_INIT_V1
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
@@ -82,12 +79,13 @@ async def _lifespan(application: FastAPI):
|
||||
# RAG Phase 3: background staleness monitor (rebuilds if >14 days old)
|
||||
async def _rag_staleness_monitor():
|
||||
"""Periodically check if the RAG index needs rebuilding."""
|
||||
import time as _time
|
||||
while True:
|
||||
await asyncio.sleep(6 * 3600) # check every 6 hours
|
||||
try:
|
||||
from services.rag_store import check_staleness, build_index
|
||||
info = check_staleness()
|
||||
if info.get("stale") and os.environ.get("RAG_AUTOBUILD", "0") == "1": # FARMHOUSE_RAG_MONITOR_V1
|
||||
if info.get("stale"):
|
||||
log.info("RAG staleness monitor: %s — triggering rebuild.", info["reason"])
|
||||
loop.run_in_executor(None, lambda: build_index(force=True))
|
||||
else:
|
||||
@@ -96,17 +94,20 @@ async def _lifespan(application: FastAPI):
|
||||
log.warning("RAG staleness monitor failed: %s", exc)
|
||||
asyncio.ensure_future(_rag_staleness_monitor())
|
||||
|
||||
# Policy model load — routes were already registered at module scope (before
|
||||
# any app.mount() calls) so /api/policy/* is reachable regardless of the
|
||||
# StaticFiles root mount. Only the heavyweight model load happens here.
|
||||
# Policy routes: register if ML policy model is available
|
||||
try:
|
||||
if _POLICY_SVC is not None:
|
||||
if _POLICY_SVC.load():
|
||||
log.info("Policy model loaded successfully")
|
||||
else:
|
||||
log.warning("Policy model not loaded — /api/policy/health will report degraded")
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
from routes.policy import register_policy_routes
|
||||
_policy_svc = PolicyInferenceService()
|
||||
if _policy_svc.load():
|
||||
register_policy_routes(application, _policy_svc)
|
||||
log.info("Policy routes registered (/api/policy/*)")
|
||||
else:
|
||||
log.warning("Policy model not loaded — /api/policy/* routes inactive")
|
||||
except ImportError:
|
||||
log.info("Policy service not available (ml.serving not installed)")
|
||||
except Exception as e:
|
||||
log.warning("Policy model load failed (non-fatal): %s", e)
|
||||
log.warning("Policy route registration failed (non-fatal): %s", e)
|
||||
|
||||
yield # ← server is running
|
||||
|
||||
@@ -130,7 +131,6 @@ app.include_router(deckbuilder_router)
|
||||
app.include_router(precon_router)
|
||||
app.include_router(import_router)
|
||||
app.include_router(lab_router)
|
||||
app.include_router(farmhouse_phase_router)
|
||||
app.include_router(scanner_router)
|
||||
app.include_router(rag_router)
|
||||
app.include_router(deepseek_router)
|
||||
@@ -138,24 +138,8 @@ app.include_router(deckgen_router)
|
||||
app.include_router(coach_router)
|
||||
app.include_router(ws_game_router)
|
||||
app.include_router(ml_router)
|
||||
app.include_router(game_router) # live game session routes
|
||||
|
||||
# ── Policy routes — MUST be registered here, before any app.mount() calls ────────────────────
|
||||
# StaticFiles mounted at "/" acts as a sub-application and returns its own 404 for
|
||||
# paths it doesn't serve, rather than falling through to later-registered routes.
|
||||
# Any include_router() called after app.mount("/", ...) will be unreachable.
|
||||
# Registering here (module scope) guarantees the /api/policy/* endpoints exist in
|
||||
# the route list before the root static mount is added below.
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
from routes.policy import register_policy_routes
|
||||
_POLICY_SVC = PolicyInferenceService()
|
||||
register_policy_routes(app, _POLICY_SVC)
|
||||
log.info("Policy routes registered (/api/policy/*)")
|
||||
except ImportError:
|
||||
log.info("Policy service not available (ml.serving not installed)")
|
||||
except Exception as e:
|
||||
log.warning("Policy route registration failed (non-fatal): %s", e)
|
||||
app.include_router(game_router) # live game session routes
|
||||
app.include_router(card_art_router) # Phase 0: /card-art/* + /api/card-image-*
|
||||
|
||||
|
||||
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -168,26 +152,24 @@ async def health_check():
|
||||
"""Health check endpoint for load balancers and Unity client polling."""
|
||||
return {"status": "ok"}
|
||||
|
||||
# ── Static UI (MUST come after all @app.get / include_router calls) ──────────────────────────
|
||||
|
||||
# ── Static mounts (MUST come after all @app.get / router includes) ────────────────────────
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# Phase 0: Serve local card images at /static/card-images/
|
||||
# MUST be mounted before the legacy UI wildcard to avoid being shadowed.
|
||||
_card_images_dir = _Path(__file__).parent / "static" / "card-images"
|
||||
_card_images_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount(
|
||||
"/static/card-images",
|
||||
StaticFiles(directory=str(_card_images_dir)),
|
||||
name="card-images"
|
||||
)
|
||||
|
||||
# Legacy UI / SPA (wildcard — MUST come last)
|
||||
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
||||
_lan_client_dir = _Path(__file__).parent / "lan-client"
|
||||
_precon_decks_dir = _Path(__file__).parent / "precon-decks"
|
||||
_user_decks_dir = _Path(__file__).parent / "user-decks"
|
||||
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
|
||||
# Mount LAN client at /lan (served before the root catch-all)
|
||||
if _lan_client_dir.exists():
|
||||
app.mount("/lan", StaticFiles(directory=str(_lan_client_dir), html=True), name="lan-client")
|
||||
|
||||
# Mount precon-decks/ so .dck files are fetchable at /precon-decks/{file}.dck
|
||||
if _precon_decks_dir.exists():
|
||||
app.mount("/precon-decks", StaticFiles(directory=str(_precon_decks_dir)), name="precon-decks")
|
||||
|
||||
# Mount user-decks/ so exported .dck files are fetchable at /user-decks/{file}.dck
|
||||
if _user_decks_dir.exists():
|
||||
app.mount("/user-decks", StaticFiles(directory=str(_user_decks_dir)), name="user-decks")
|
||||
|
||||
if _legacy_ui_dir.exists():
|
||||
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
|
||||
elif _spa_dir.exists():
|
||||
@@ -204,16 +186,14 @@ elif _spa_dir.exists():
|
||||
return FileResponse(str(_spa_dir / "index.html"))
|
||||
|
||||
|
||||
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
|
||||
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────
|
||||
def _parse_args():
|
||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||
# Support both FORGE_DIR and FORGE_WORKING_DIR (alias for backwards compat)
|
||||
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR") or os.environ.get("FORGE_WORKING_DIR", ""))
|
||||
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
|
||||
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
|
||||
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
|
||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||
p.add_argument("--results-dir", default=os.environ.get("RESULTS_DIR", ""))
|
||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
||||
@@ -259,9 +239,7 @@ def _resolve_forge_dir() -> str:
|
||||
project_root = Path(__file__).parent
|
||||
for candidate in [
|
||||
project_root / "forge",
|
||||
project_root.parent / "Forge-Repo",
|
||||
project_root.parent / "Forge-Repo" / "forge-gui",
|
||||
project_root.parent / "forge-repo", # legacy fallback
|
||||
project_root.parent / "forge-repo",
|
||||
project_root.parent / "forge-repo" / "forge-gui",
|
||||
project_root.parent / "forge",
|
||||
project_root / "forge-gui-desktop",
|
||||
@@ -287,11 +265,10 @@ def _resolve_forge_jar() -> str:
|
||||
project_root = Path(__file__).parent
|
||||
candidates = [
|
||||
project_root / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "Forge-Repo" / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback
|
||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
||||
]
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "")
|
||||
if forge_dir_env:
|
||||
fd = Path(forge_dir_env)
|
||||
candidates.append(fd.parent / "forge-gui-desktop" / "target")
|
||||
@@ -356,15 +333,6 @@ def main():
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.anthropic_api_key = args.anthropic_key
|
||||
|
||||
# Wire RESULTS_DIR: use explicit env/arg if set, otherwise keep default "results"
|
||||
# but resolve it to an absolute path relative to this file so it works
|
||||
# regardless of what directory the server is launched from.
|
||||
if args.results_dir:
|
||||
CFG.results_dir = args.results_dir
|
||||
else:
|
||||
# Default: results/ next to lab_api.py (always absolute)
|
||||
CFG.results_dir = str(Path(__file__).parent / "results")
|
||||
|
||||
from services.forge_runner import get_java17
|
||||
j17 = get_java17()
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,34 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Commander AI Lab — Collection</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="global.css" />
|
||||
<link rel="stylesheet" href="collection.css" />
|
||||
<style>
|
||||
/* No nav bar when embedded in LAN client iframe */
|
||||
:root { --nav-height: 0px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- nav.js removed — collection is embedded via iframe in the LAN client -->
|
||||
<div id="collection-container"></div>
|
||||
<script>
|
||||
window.LAB_API_BASE = window.LAB_API_BASE || '';
|
||||
</script>
|
||||
<script src="collection.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
Collection.init();
|
||||
});
|
||||
</script>
|
||||
<!-- Card Preview Popup (hover tooltip) -->
|
||||
<div id="coll-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:9999;">
|
||||
<img id="coll-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,495 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Commander AI Lab — Deck Builder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="global.css" />
|
||||
<link rel="stylesheet" href="deckbuilder.css" />
|
||||
<style>
|
||||
/* No nav bar when embedded in LAN client iframe */
|
||||
:root { --nav-height: 0px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- nav.js removed — deck builder is embedded via iframe in the LAN client -->
|
||||
|
||||
<div id="deck-builder-page">
|
||||
|
||||
<!-- TOP BAR -->
|
||||
<header class="deck-topbar">
|
||||
<div class="deck-topbar-commander">
|
||||
<div class="deck-commander-portrait" id="deck-commander-portrait">
|
||||
<span class="deck-commander-portrait-placeholder">⚔</span>
|
||||
<img class="deck-commander-portrait-img" id="deck-commander-img" src="" alt="" style="display:none" />
|
||||
</div>
|
||||
<div class="deck-name-block">
|
||||
<input
|
||||
type="text"
|
||||
class="deck-name-input"
|
||||
id="deck-name-input"
|
||||
placeholder="Untitled Deck"
|
||||
value=""
|
||||
/>
|
||||
<div class="deck-commander-label" id="deck-commander-label">No commander selected</div>
|
||||
</div>
|
||||
<div class="deck-color-chips" id="deck-color-chips"></div>
|
||||
</div>
|
||||
|
||||
<div class="deck-topbar-center">
|
||||
<div class="deck-count-display">
|
||||
<span class="deck-count-num" id="deck-total-count">0</span>
|
||||
<span class="deck-count-sep">/</span>
|
||||
<span class="deck-count-target">100</span>
|
||||
<span class="deck-count-label">cards</span>
|
||||
</div>
|
||||
<button class="deck-btn deck-btn-ghost" id="deck-new-btn">+ New Deck</button>
|
||||
<div class="deck-selector-wrap">
|
||||
<select class="deck-selector" id="deck-selector">
|
||||
<option value="">— Select Deck —</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="deck-btn deck-btn-danger" id="deck-delete-btn" title="Delete this deck" style="display:none;">✕ Delete</button>
|
||||
<button class="deck-btn deck-btn-danger-outline" id="deck-clear-all-btn" title="Delete all decks">Clear All</button>
|
||||
</div>
|
||||
|
||||
<div class="deck-topbar-actions">
|
||||
<button class="deck-btn deck-btn-import" id="deck-import-btn" title="Import a decklist">
|
||||
<span>⬆</span> Import
|
||||
</button>
|
||||
<button class="deck-btn deck-btn-ghost" id="deck-export-btn" title="Export as .dck file">
|
||||
<span>⬇</span> Export .dck
|
||||
</button>
|
||||
<button class="deck-btn deck-btn-ghost" id="deck-export-sim-btn" title="Export deck as .dck to Forge sim folder">
|
||||
<span>📤</span> Export to Sim
|
||||
</button>
|
||||
<button class="deck-btn deck-btn-ghost" id="deck-export-game-btn" title="Send deck to LAN game setup" style="border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);">
|
||||
<span>🎮</span> Use in Game
|
||||
</button>
|
||||
<button class="deck-btn deck-btn-sim" id="deck-sim-btn" title="Run quick simulation">
|
||||
<span class="deck-btn-icon">▶</span> Run Simulation
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- MAIN 3-COLUMN LAYOUT -->
|
||||
<main class="deck-main">
|
||||
|
||||
<!-- LEFT COLUMN: Collection Search -->
|
||||
<aside class="deck-col-left">
|
||||
<div class="deck-panel deck-panel-search">
|
||||
<div class="deck-panel-header">
|
||||
<span class="deck-panel-title">Collection Search</span>
|
||||
<span class="deck-search-count" id="deck-search-count">0 results</span>
|
||||
</div>
|
||||
|
||||
<div class="deck-search-bar-wrap">
|
||||
<input
|
||||
type="search"
|
||||
class="deck-search-bar"
|
||||
id="deck-search-bar"
|
||||
placeholder="Search cards..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="deck-search-icon">🔍</span>
|
||||
</div>
|
||||
|
||||
<div class="deck-filter-row">
|
||||
<select class="deck-filter-select" id="deck-type-filter">
|
||||
<option value="">All Types</option>
|
||||
<option value="Land">Land</option>
|
||||
<option value="Creature">Creature</option>
|
||||
<option value="Instant">Instant</option>
|
||||
<option value="Sorcery">Sorcery</option>
|
||||
<option value="Artifact">Artifact</option>
|
||||
<option value="Enchantment">Enchantment</option>
|
||||
<option value="Planeswalker">Planeswalker</option>
|
||||
</select>
|
||||
<div class="deck-cmc-filter">
|
||||
<input type="number" class="deck-cmc-input" id="deck-cmc-min" placeholder="0" min="0" max="20" />
|
||||
<span class="deck-cmc-sep">–</span>
|
||||
<input type="number" class="deck-cmc-input" id="deck-cmc-max" placeholder="∞" min="0" max="20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="deck-color-filter-row" id="deck-color-filter">
|
||||
<button class="deck-color-pip deck-color-W" data-color="W" title="White">W</button>
|
||||
<button class="deck-color-pip deck-color-U" data-color="U" title="Blue">U</button>
|
||||
<button class="deck-color-pip deck-color-B" data-color="B" title="Black">B</button>
|
||||
<button class="deck-color-pip deck-color-R" data-color="R" title="Red">R</button>
|
||||
<button class="deck-color-pip deck-color-G" data-color="G" title="Green">G</button>
|
||||
<button class="deck-color-pip deck-color-C" data-color="C" title="Colorless">C</button>
|
||||
</div>
|
||||
|
||||
<!-- Results list -->
|
||||
<div class="deck-search-results" id="deck-search-results">
|
||||
<div class="deck-empty-state">
|
||||
<div class="deck-empty-icon">🃏</div>
|
||||
<div class="deck-empty-text">Search your collection to add cards</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="deck-search-footer">
|
||||
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-add-all-owned-btn" style="display:none">
|
||||
Add All Matching (Owned)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER COLUMN: Deck Card List -->
|
||||
<section class="deck-col-center">
|
||||
<div class="deck-panel deck-panel-cards" id="deck-card-list-panel">
|
||||
<div class="deck-panel-header deck-card-list-header">
|
||||
<span class="deck-panel-title">Deck Contents</span>
|
||||
<div class="deck-card-list-actions">
|
||||
<span class="deck-card-list-meta" id="deck-card-list-meta">Select or create a deck</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card groups by type -->
|
||||
<div class="deck-card-groups" id="deck-card-groups">
|
||||
<div class="deck-empty-state deck-empty-state-lg">
|
||||
<div class="deck-empty-icon">⚔</div>
|
||||
<div class="deck-empty-text">Create or select a deck to start building</div>
|
||||
<button class="deck-btn deck-btn-accent" id="deck-empty-new-btn">+ New Deck</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick add by name -->
|
||||
<div class="deck-quickadd-row" id="deck-quickadd-row" style="display:none">
|
||||
<div class="deck-quickadd-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="deck-quickadd-input"
|
||||
id="deck-quickadd-input"
|
||||
placeholder="Add card by name..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="deck-quickadd-dropdown" id="deck-quickadd-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
<button class="deck-btn deck-btn-accent-sm" id="deck-quickadd-btn">Add</button>
|
||||
</div>
|
||||
|
||||
<!-- Deck stats footer -->
|
||||
<div class="deck-stats-footer" id="deck-stats-footer" style="display:none">
|
||||
<div class="deck-stat-pill">
|
||||
<span class="deck-stat-pill-num" id="deck-footer-total">0</span>
|
||||
<span class="deck-stat-pill-label">total</span>
|
||||
</div>
|
||||
<div class="deck-stat-pill">
|
||||
<span class="deck-stat-pill-num deck-stat-owned" id="deck-footer-owned">0</span>
|
||||
<span class="deck-stat-pill-label">owned</span>
|
||||
</div>
|
||||
<div class="deck-stat-pill">
|
||||
<span class="deck-stat-pill-num" id="deck-footer-avg-cmc">0.0</span>
|
||||
<span class="deck-stat-pill-label">avg CMC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- RIGHT COLUMN: EDH Advisor -->
|
||||
<aside class="deck-col-right">
|
||||
|
||||
<!-- Ratio Dashboard -->
|
||||
<div class="deck-panel deck-panel-ratios" id="deck-ratio-panel">
|
||||
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ratio-body">
|
||||
<span class="deck-panel-title">📊 Ratio Dashboard</span>
|
||||
<span class="deck-collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="deck-panel-body" id="deck-ratio-body">
|
||||
<div class="deck-ratio-bars" id="deck-ratio-bars">
|
||||
<div class="deck-ratio-placeholder">Load a deck to see ratios</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fix My Ratios -->
|
||||
<div class="deck-panel deck-panel-fix" id="deck-fix-panel">
|
||||
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-fix-body">
|
||||
<span class="deck-panel-title">🔧 Fix My Ratios</span>
|
||||
<span class="deck-collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="deck-panel-body" id="deck-fix-body">
|
||||
<div class="deck-shortfalls" id="deck-shortfalls">
|
||||
<div class="deck-ratio-placeholder">No analysis available</div>
|
||||
</div>
|
||||
<div class="deck-fix-actions" id="deck-fix-actions" style="display:none">
|
||||
<button class="deck-btn deck-btn-ghost deck-btn-full" id="deck-autosuggest-btn">
|
||||
⚡ Auto-suggest from Collection
|
||||
</button>
|
||||
</div>
|
||||
<div class="deck-fix-suggestions" id="deck-fix-suggestions" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDHREC Recommendations -->
|
||||
<div class="deck-panel deck-panel-edhrec" id="deck-edhrec-panel">
|
||||
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-edhrec-body">
|
||||
<span class="deck-panel-title">✨ EDHREC Recs</span>
|
||||
<span class="deck-collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="deck-panel-body" id="deck-edhrec-body">
|
||||
<div class="deck-edhrec-controls">
|
||||
<label class="deck-toggle-label">
|
||||
<input type="checkbox" class="deck-toggle-check" id="deck-edhrec-owned-only" />
|
||||
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
|
||||
Owned Only
|
||||
</label>
|
||||
<button class="deck-btn deck-btn-ghost deck-btn-sm" id="deck-edhrec-refresh-btn">
|
||||
↺ Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="deck-edhrec-list" id="deck-edhrec-list">
|
||||
<div class="deck-ratio-placeholder">Load a deck to see recommendations</div>
|
||||
</div>
|
||||
<div class="deck-edhrec-bulk" id="deck-edhrec-bulk" style="display:none">
|
||||
<button class="deck-btn deck-btn-success deck-btn-full" id="deck-edhrec-bulk-owned-btn">
|
||||
Add All Owned (respect ratios)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Research (Perplexity) — hidden until pplx status check -->
|
||||
<div class="deck-panel deck-panel-ai" id="deck-ai-research-panel" style="display:none">
|
||||
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-research-body">
|
||||
<span class="deck-panel-title">🧠 AI Research</span>
|
||||
<span class="deck-collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="deck-panel-body" id="deck-ai-research-body">
|
||||
<div class="deck-ai-controls">
|
||||
<div class="deck-form-field deck-form-field-sm">
|
||||
<label class="deck-form-label-sm">Analysis Goal</label>
|
||||
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-goal"
|
||||
placeholder="Identify weaknesses and suggest upgrades" />
|
||||
</div>
|
||||
<div class="deck-ai-row">
|
||||
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
|
||||
<label class="deck-form-label-sm">Budget ($)</label>
|
||||
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-research-budget"
|
||||
placeholder="No limit" min="0" step="10" />
|
||||
</div>
|
||||
<label class="deck-toggle-label deck-toggle-label-sm">
|
||||
<input type="checkbox" class="deck-toggle-check" id="ai-research-use-collection" checked />
|
||||
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
|
||||
Use Collection
|
||||
</label>
|
||||
</div>
|
||||
<div class="deck-form-field deck-form-field-sm">
|
||||
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
|
||||
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-research-omit"
|
||||
placeholder="Dockside Extortionist, Rhystic Study" />
|
||||
</div>
|
||||
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-research-btn" disabled>
|
||||
🔍 Analyze Deck
|
||||
</button>
|
||||
</div>
|
||||
<div class="deck-ai-loading" id="ai-research-loading" style="display:none">
|
||||
<div class="deck-ai-spinner"></div>
|
||||
<span>Analyzing with Claude Opus...</span>
|
||||
</div>
|
||||
<div class="deck-ai-results" id="ai-research-results" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Deck Generate (Perplexity) — hidden until pplx status check -->
|
||||
<div class="deck-panel deck-panel-ai" id="deck-ai-generate-panel" style="display:none">
|
||||
<div class="deck-panel-header deck-panel-header-collapsible" data-target="deck-ai-generate-body">
|
||||
<span class="deck-panel-title">✨ AI Generate Deck</span>
|
||||
<span class="deck-collapse-arrow">▼</span>
|
||||
</div>
|
||||
<div class="deck-panel-body" id="deck-ai-generate-body">
|
||||
<div class="deck-ai-controls">
|
||||
<div class="deck-form-field deck-form-field-sm">
|
||||
<label class="deck-form-label-sm">Commander</label>
|
||||
<div class="deck-ai-commander-wrap">
|
||||
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-commander"
|
||||
placeholder="Search for a commander..." autocomplete="off" />
|
||||
<div class="deck-ai-commander-dropdown" id="ai-gen-commander-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="deck-ai-row">
|
||||
<div class="deck-form-field deck-form-field-sm deck-form-field-half">
|
||||
<label class="deck-form-label-sm">Budget ($)</label>
|
||||
<input type="number" class="deck-form-input deck-form-input-sm" id="ai-gen-budget"
|
||||
placeholder="No limit" min="0" step="10" />
|
||||
</div>
|
||||
<label class="deck-toggle-label deck-toggle-label-sm">
|
||||
<input type="checkbox" class="deck-toggle-check" id="ai-gen-use-collection" checked />
|
||||
<span class="deck-toggle-track"><span class="deck-toggle-thumb"></span></span>
|
||||
Use Collection
|
||||
</label>
|
||||
</div>
|
||||
<div class="deck-form-field deck-form-field-sm">
|
||||
<label class="deck-form-label-sm">Omit Cards (comma-separated)</label>
|
||||
<input type="text" class="deck-form-input deck-form-input-sm" id="ai-gen-omit"
|
||||
placeholder="Rhystic Study, Cyclonic Rift" />
|
||||
</div>
|
||||
<button class="deck-btn deck-btn-ai deck-btn-full" id="ai-gen-btn">
|
||||
✨ Generate Full Deck
|
||||
</button>
|
||||
</div>
|
||||
<div class="deck-ai-loading" id="ai-gen-loading" style="display:none">
|
||||
<div class="deck-ai-spinner"></div>
|
||||
<span>Generating deck with Claude Opus...</span>
|
||||
</div>
|
||||
<div class="deck-ai-results" id="ai-gen-results" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- CARD PREVIEW TOOLTIP -->
|
||||
<div class="deck-card-preview" id="deck-card-preview" style="display:none">
|
||||
<img class="deck-card-preview-img" id="deck-card-preview-img" src="" alt="" />
|
||||
</div>
|
||||
|
||||
<!-- TOAST CONTAINER -->
|
||||
<div class="deck-toast-container" id="deck-toast-container"></div>
|
||||
|
||||
<!-- CONFIRM MODAL -->
|
||||
<div class="deck-modal-overlay" id="deck-confirm-modal" style="display:none">
|
||||
<div class="deck-modal">
|
||||
<div class="deck-modal-header">
|
||||
<h3 class="deck-modal-title" id="deck-confirm-title">Confirm Action</h3>
|
||||
<button class="deck-modal-close-btn" id="deck-confirm-close">✕</button>
|
||||
</div>
|
||||
<div class="deck-modal-body">
|
||||
<p class="deck-confirm-message" id="deck-confirm-message"></p>
|
||||
<div class="deck-confirm-changes" id="deck-confirm-changes"></div>
|
||||
</div>
|
||||
<div class="deck-modal-footer">
|
||||
<button class="deck-btn deck-btn-ghost" id="deck-confirm-cancel">Cancel</button>
|
||||
<button class="deck-btn deck-btn-accent" id="deck-confirm-ok">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NEW DECK MODAL -->
|
||||
<div class="deck-modal-overlay" id="deck-new-modal" style="display:none">
|
||||
<div class="deck-modal">
|
||||
<div class="deck-modal-header">
|
||||
<h3 class="deck-modal-title">Create New Deck</h3>
|
||||
<button class="deck-modal-close-btn" id="deck-new-modal-close">✕</button>
|
||||
</div>
|
||||
<div class="deck-modal-body">
|
||||
<div class="deck-form-field">
|
||||
<label class="deck-form-label">Deck Name</label>
|
||||
<input type="text" class="deck-form-input" id="deck-new-name" placeholder="My Commander Deck" />
|
||||
</div>
|
||||
<div class="deck-form-field">
|
||||
<label class="deck-form-label">Commander (optional)</label>
|
||||
<div class="deck-commander-search-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="deck-form-input"
|
||||
id="deck-new-commander-search"
|
||||
placeholder="Search Scryfall for commander..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="deck-commander-dropdown" id="deck-new-commander-dropdown" style="display:none"></div>
|
||||
</div>
|
||||
<div class="deck-new-commander-selected" id="deck-new-commander-selected" style="display:none">
|
||||
<span class="deck-selected-commander-name" id="deck-new-commander-name"></span>
|
||||
<button class="deck-clear-commander-btn" id="deck-clear-commander-btn">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="deck-modal-footer">
|
||||
<button class="deck-btn deck-btn-ghost" id="deck-new-cancel">Cancel</button>
|
||||
<button class="deck-btn deck-btn-accent" id="deck-new-create">Create Deck</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IMPORT MODAL -->
|
||||
<div class="deck-modal-overlay" id="deck-import-modal" style="display:none">
|
||||
<div class="deck-modal deck-modal-wide">
|
||||
<div class="deck-modal-header">
|
||||
<h3 class="deck-modal-title">Import Decklist</h3>
|
||||
<button class="deck-modal-close-btn" id="deck-import-modal-close">✕</button>
|
||||
</div>
|
||||
<div class="deck-modal-body">
|
||||
<div class="deck-import-tabs">
|
||||
<button class="deck-import-tab active" data-tab="paste">Paste Text</button>
|
||||
<button class="deck-import-tab" data-tab="file">Upload File</button>
|
||||
</div>
|
||||
|
||||
<div id="deck-import-paste-panel" class="deck-import-panel">
|
||||
<textarea id="deck-import-textarea" rows="14" placeholder="Paste your decklist here...
|
||||
|
||||
Supported formats:
|
||||
[Commander]
|
||||
1 Korvold, Fae-Cursed King
|
||||
|
||||
[Deck]
|
||||
1 Sol Ring
|
||||
1 Cultivate
|
||||
37 Forest
|
||||
|
||||
Or plain list:
|
||||
Sol Ring
|
||||
Cultivate
|
||||
Forest"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="deck-import-file-panel" class="deck-import-panel" style="display:none">
|
||||
<div class="deck-import-dropzone" id="deck-import-dropzone">
|
||||
<div class="deck-import-dropzone-label">Drop a .dck or .txt file here, or click to browse</div>
|
||||
<input type="file" id="deck-import-file" accept=".dck,.txt,.dec,.mwDeck" style="display:none" />
|
||||
</div>
|
||||
<div class="deck-import-file-info" id="deck-import-file-name"></div>
|
||||
</div>
|
||||
|
||||
<div class="deck-import-options">
|
||||
<label class="deck-import-option-label">
|
||||
<input type="radio" name="import-mode" value="new" checked /> Create new deck
|
||||
</label>
|
||||
<label class="deck-import-option-label">
|
||||
<input type="radio" name="import-mode" value="add" /> Add to current deck
|
||||
</label>
|
||||
<label class="deck-import-option-label">
|
||||
<input type="radio" name="import-mode" value="replace" /> Replace current deck cards
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="deck-import-new-name-row" class="deck-import-name-row">
|
||||
<label>Deck name (optional):</label>
|
||||
<input type="text" id="deck-import-name" placeholder="Auto-detected from commander" />
|
||||
</div>
|
||||
|
||||
<div class="deck-import-progress-wrap" id="deck-import-progress" style="display:none">
|
||||
<div class="deck-import-progress-label">Importing cards (Scryfall lookup)...</div>
|
||||
<div class="deck-import-progress-track">
|
||||
<div class="deck-import-progress-fill" id="deck-import-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="deck-import-result" style="display:none"></div>
|
||||
</div>
|
||||
<div class="deck-modal-footer">
|
||||
<button class="deck-btn deck-btn-ghost" id="deck-import-cancel">Cancel</button>
|
||||
<button class="deck-btn deck-btn-accent" id="deck-import-go">Import</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /#deck-builder-page -->
|
||||
|
||||
<script>
|
||||
// Set API base URL — when served from a different host, override this value
|
||||
window.LAB_API_BASE = window.LAB_API_BASE || '';
|
||||
</script>
|
||||
<script src="deckbuilder.js"></script>
|
||||
<!-- Card Preview Popup (hover tooltip) -->
|
||||
<div id="deck-card-preview" style="display:none; position:fixed; pointer-events:none; z-index:60;">
|
||||
<img id="deck-card-preview-img" src="" alt="Card preview" style="width:220px; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.7);" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,245 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Commander AI Lab — Auto Deck Generator V3</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="global.css" />
|
||||
<link rel="stylesheet" href="deckgenerator.css" />
|
||||
<style>
|
||||
/* No nav bar when embedded in LAN client iframe */
|
||||
:root { --nav-height: 0px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- nav.js removed — deck generator is embedded via iframe in the LAN client -->
|
||||
|
||||
<div id="deckgen-page">
|
||||
|
||||
<!-- MAIN LAYOUT -->
|
||||
<main class="dg-main">
|
||||
|
||||
<!-- LEFT: Configuration -->
|
||||
<section class="dg-col-config">
|
||||
|
||||
<!-- Commander Search -->
|
||||
<div class="dg-panel dg-panel-commander">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Commander</span>
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<div class="dg-commander-search-wrap">
|
||||
<input
|
||||
type="text"
|
||||
class="dg-input"
|
||||
id="dg-commander-input"
|
||||
placeholder="Search for a legendary creature..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="dg-commander-dropdown" id="dg-commander-dropdown"></div>
|
||||
</div>
|
||||
<div class="dg-commander-selected" id="dg-commander-selected" style="display:none">
|
||||
<div class="dg-commander-card">
|
||||
<button class="dg-commander-clear" id="dg-commander-clear" title="Clear commander">×</button>
|
||||
<img class="dg-commander-img" id="dg-commander-img" src="" alt="" />
|
||||
<div class="dg-commander-info">
|
||||
<div class="dg-commander-name" id="dg-commander-name"></div>
|
||||
<div class="dg-commander-type" id="dg-commander-type"></div>
|
||||
<div class="dg-commander-colors" id="dg-commander-colors"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Strategy & Bracket -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Strategy & Power</span>
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<input
|
||||
type="text"
|
||||
class="dg-input"
|
||||
id="dg-strategy"
|
||||
placeholder="e.g. zombie tokens, voltron, spellslinger..."
|
||||
title="Describe your deck strategy — the AI uses this to pick thematically aligned cards"
|
||||
/>
|
||||
<div class="dg-bracket-row">
|
||||
<label class="dg-label">
|
||||
Target Bracket
|
||||
<span class="tip-icon" data-tip="B1 = precon-level casual. B2 = upgraded with synergies. B3 = high power with strong combos. B4 = cEDH with maximum optimization, fast mana, and instant-speed interaction.">?</span>
|
||||
</label>
|
||||
<div class="dg-bracket-btns" id="dg-bracket-btns">
|
||||
<button class="dg-bracket-btn" data-bracket="1" title="Casual — precon level">B1</button>
|
||||
<button class="dg-bracket-btn" data-bracket="2" title="Upgraded Casual">B2</button>
|
||||
<button class="dg-bracket-btn dg-bracket-active" data-bracket="3" title="High Power">B3</button>
|
||||
<button class="dg-bracket-btn" data-bracket="4" title="cEDH — maximum power">B4</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deck Name -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Deck Name</span>
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<input
|
||||
type="text"
|
||||
class="dg-input"
|
||||
id="dg-deck-name"
|
||||
placeholder="Auto-generated from commander name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Budget</span>
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<div class="dg-budget-row">
|
||||
<input
|
||||
type="number"
|
||||
class="dg-input dg-input-narrow"
|
||||
id="dg-budget"
|
||||
placeholder="No limit"
|
||||
min="0"
|
||||
step="25"
|
||||
/>
|
||||
<select class="dg-select" id="dg-budget-mode" title="Total Deck: caps the combined price of all 100 cards. Per Card Max: caps each individual card price.">
|
||||
<option value="total">Total Deck $</option>
|
||||
<option value="per_card">Per Card Max $</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Options</span>
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<label class="dg-toggle-row">
|
||||
<span class="dg-toggle-label">
|
||||
Use my collection
|
||||
<span class="tip-icon" data-tip="Prioritizes cards you already own before pulling from the full card pool. Saves money and ensures the deck is ready to play. Requires an imported collection.">?</span>
|
||||
</span>
|
||||
<input type="checkbox" class="dg-toggle-check" id="dg-use-collection" checked />
|
||||
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||
</label>
|
||||
<label class="dg-toggle-row">
|
||||
<span class="dg-toggle-label">
|
||||
Smart Substitution
|
||||
<span class="tip-icon" data-tip="When a suggested card isn't in your collection, automatically finds the closest functional replacement you do own. Keeps the deck playable without buying extra cards.">?</span>
|
||||
</span>
|
||||
<input type="checkbox" class="dg-toggle-check" id="dg-substitution" checked />
|
||||
<span class="dg-toggle-track"><span class="dg-toggle-thumb"></span></span>
|
||||
</label>
|
||||
<div class="dg-model-row">
|
||||
<label class="dg-label">
|
||||
AI Model
|
||||
<span class="tip-icon" data-tip="Deck generation is powered by Claude Opus 4, Anthropic's most capable model.">?</span>
|
||||
</label>
|
||||
<input type="hidden" id="dg-model" value="claude-opus-4-20250514" />
|
||||
<span style="font-size:0.8rem; color:var(--text-muted, #888);">claude-opus-4-20250514</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Omit Cards (chips input) -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Omit Cards</span>
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<div class="dg-omit-wrap" id="dg-omit-wrap">
|
||||
<div class="dg-omit-chips" id="dg-omit-chips"></div>
|
||||
<input
|
||||
type="text"
|
||||
class="dg-input dg-omit-input"
|
||||
id="dg-omit-input"
|
||||
placeholder="Type a card name and press Enter…"
|
||||
/>
|
||||
</div>
|
||||
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="dg-actions">
|
||||
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
|
||||
<span class="dg-btn-icon">⚡</span> Generate Deck
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Log -->
|
||||
<div id="dg-conversation-log" class="dg-conversation-log">
|
||||
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- RIGHT: Preview Results -->
|
||||
<section class="dg-col-results">
|
||||
<!-- Empty State -->
|
||||
<div class="dg-empty-state" id="dg-empty-state">
|
||||
<div class="dg-empty-icon">⚡</div>
|
||||
<div class="dg-empty-title">V3 AI Deck Generator</div>
|
||||
<div class="dg-empty-text">Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="dg-loading" id="dg-loading" style="display:none">
|
||||
<div class="dg-spinner"></div>
|
||||
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Claude Opus 4...</div>
|
||||
</div>
|
||||
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
|
||||
<div class="dg-progress-bar">
|
||||
<div class="dg-progress-fill" id="dg-progress-fill"></div>
|
||||
</div>
|
||||
<div class="dg-progress-meta">
|
||||
<span class="dg-progress-phase" id="dg-progress-phase"></span>
|
||||
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="dg-results" id="dg-results" style="display:none">
|
||||
<div class="dg-results-header">
|
||||
<div id="dg-strategy-bar"></div>
|
||||
<div id="dg-stats-bar" class="dg-stats-bar"></div>
|
||||
<div id="dg-sub-summary" style="display:none"></div>
|
||||
<div class="dg-result-actions">
|
||||
<button class="dg-btn dg-btn-secondary" id="dg-regenerate-btn">↻ Regenerate</button>
|
||||
<button class="dg-btn dg-btn-primary" id="dg-save-btn">💾 Save to Deck Builder</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-csv">CSV</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-dck">Forge .dck</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-moxfield">Moxfield</button>
|
||||
<button class="dg-btn dg-btn-ghost" id="dg-export-shopping">🛒 Shopping</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dg-card-groups" class="dg-card-groups"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- TOAST CONTAINER -->
|
||||
<div class="dg-toast-container" id="dg-toast-container"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.LAB_API_BASE = window.LAB_API_BASE || '';
|
||||
</script>
|
||||
<script src="deckgenerator.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,795 +0,0 @@
|
||||
/**
|
||||
* Commander AI Lab — Auto Deck Generator V3 UI
|
||||
* ═══════════════════════════════════════════════
|
||||
*
|
||||
* V3 Endpoints:
|
||||
* GET /api/deck/v3/status — check V3 generator status
|
||||
* GET /api/deck-generator/commander-search — autocomplete commanders
|
||||
* POST /api/deck/v3/generate — generate deck (local AI structured output)
|
||||
* POST /api/deck/v3/commit — generate + save to Deck Builder
|
||||
* POST /api/deck/v3/export/csv — export as CSV
|
||||
* POST /api/deck/v3/export/dck — export as Forge .dck
|
||||
* POST /api/deck/v3/export/moxfield — export in Moxfield paste format
|
||||
* POST /api/deck/v3/export/shopping — shopping list of missing cards
|
||||
*/
|
||||
|
||||
const DeckGenerator = (() => {
|
||||
'use strict';
|
||||
|
||||
const API_BASE = window.LAB_API_BASE || window.location.origin;
|
||||
const DEBOUNCE_MS = 350;
|
||||
|
||||
const COLOR_MAP = {
|
||||
W: { label: 'W', cls: 'dg-pip-W' },
|
||||
U: { label: 'U', cls: 'dg-pip-U' },
|
||||
B: { label: 'B', cls: 'dg-pip-B' },
|
||||
R: { label: 'R', cls: 'dg-pip-R' },
|
||||
G: { label: 'G', cls: 'dg-pip-G' },
|
||||
C: { label: 'C', cls: 'dg-pip-C' },
|
||||
};
|
||||
|
||||
const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other'];
|
||||
const TYPE_ICONS = {
|
||||
'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧',
|
||||
'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔',
|
||||
'Other': '🃏',
|
||||
};
|
||||
|
||||
const STATUS_BADGES = {
|
||||
owned: { label: 'Owned', cls: 'dg-badge-owned' },
|
||||
substituted: { label: 'Substituted', cls: 'dg-badge-substituted' },
|
||||
missing: { label: 'Missing', cls: 'dg-badge-missing' },
|
||||
};
|
||||
|
||||
// ── State ──────────────────────────────────────────────
|
||||
let state = {
|
||||
commander: null,
|
||||
targetBracket: 3,
|
||||
previewResult: null,
|
||||
lastRequestBody: null,
|
||||
isLoading: false,
|
||||
v3Ready: false,
|
||||
};
|
||||
|
||||
let searchTimeout = null;
|
||||
|
||||
// ── Progress bar state ──────────────────────────────
|
||||
const PROGRESS_PHASES = [
|
||||
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
|
||||
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
|
||||
{ at: 6, pct: 18, msg: 'Building collection summary...' },
|
||||
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
|
||||
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
|
||||
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
|
||||
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
|
||||
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
|
||||
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
|
||||
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
|
||||
];
|
||||
let _progressInterval = null;
|
||||
let _progressStart = 0;
|
||||
|
||||
function startProgress() {
|
||||
_progressStart = Date.now();
|
||||
const wrap = $('dg-progress-wrap');
|
||||
const fill = $('dg-progress-fill');
|
||||
const phase = $('dg-progress-phase');
|
||||
const elapsed = $('dg-progress-elapsed');
|
||||
if (!wrap) return;
|
||||
wrap.style.display = 'block';
|
||||
fill.style.width = '0%';
|
||||
phase.textContent = '';
|
||||
elapsed.textContent = '0s elapsed';
|
||||
let lastPhaseIdx = -1;
|
||||
_progressInterval = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
||||
elapsed.textContent = secs + 's elapsed';
|
||||
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
|
||||
if (secs >= PROGRESS_PHASES[i].at) {
|
||||
fill.style.width = PROGRESS_PHASES[i].pct + '%';
|
||||
if (i !== lastPhaseIdx) {
|
||||
phase.textContent = PROGRESS_PHASES[i].msg;
|
||||
logMessage(PROGRESS_PHASES[i].msg, 'info');
|
||||
lastPhaseIdx = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopProgress(success) {
|
||||
if (_progressInterval) {
|
||||
clearInterval(_progressInterval);
|
||||
_progressInterval = null;
|
||||
}
|
||||
const fill = $('dg-progress-fill');
|
||||
const phase = $('dg-progress-phase');
|
||||
const elapsed = $('dg-progress-elapsed');
|
||||
if (fill) fill.style.width = success ? '100%' : '0%';
|
||||
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
|
||||
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
||||
if (elapsed) elapsed.textContent = secs + 's total';
|
||||
}
|
||||
|
||||
// ── Omit Cards (chips) ─────────────────────────────
|
||||
let omitCards = [];
|
||||
|
||||
function addOmitCard(name) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
|
||||
omitCards.push(trimmed);
|
||||
renderOmitChips();
|
||||
}
|
||||
|
||||
function removeOmitCard(idx) {
|
||||
omitCards.splice(idx, 1);
|
||||
renderOmitChips();
|
||||
}
|
||||
|
||||
function renderOmitChips() {
|
||||
const container = $('dg-omit-chips');
|
||||
if (!container) return;
|
||||
container.innerHTML = omitCards.map((name, i) =>
|
||||
'<span class="dg-chip">' +
|
||||
escHtml(name) +
|
||||
'<span class="dg-chip-x" data-idx="' + i + '">×</span>' +
|
||||
'</span>'
|
||||
).join('');
|
||||
container.querySelectorAll('.dg-chip-x').forEach(el => {
|
||||
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ── Init ────────────────────────────────────────────────
|
||||
async function init() {
|
||||
await checkV3Status();
|
||||
bindEvents();
|
||||
checkQueryParams();
|
||||
}
|
||||
|
||||
async function checkV3Status() {
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/deck/v3/status');
|
||||
const data = await resp.json();
|
||||
state.v3Ready = data.initialized;
|
||||
if (!state.v3Ready) {
|
||||
// Warn but do not block — local/Ollama mode may still work
|
||||
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
|
||||
toast('V3 generator not ready — check that the Anthropic API key is configured', 'warning');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('V3 status check failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// Commander search
|
||||
const input = $('dg-commander-input');
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
const q = input.value.trim();
|
||||
if (q.length < 2) { hideDropdown(); return; }
|
||||
searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
const q = input.value.trim();
|
||||
if (q.length >= 2) searchCommander(q);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const wrap = document.querySelector('.dg-commander-search-wrap');
|
||||
if (wrap && !wrap.contains(e.target)) hideDropdown();
|
||||
});
|
||||
|
||||
$('dg-commander-clear').addEventListener('click', clearCommander);
|
||||
|
||||
// Bracket buttons
|
||||
document.querySelectorAll('.dg-bracket-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active'));
|
||||
btn.classList.add('dg-bracket-active');
|
||||
state.targetBracket = parseInt(btn.dataset.bracket, 10);
|
||||
});
|
||||
});
|
||||
|
||||
// Generate & save
|
||||
$('dg-preview-btn').addEventListener('click', generateDeck);
|
||||
$('dg-save-btn').addEventListener('click', commitDeck);
|
||||
$('dg-regenerate-btn').addEventListener('click', generateDeck);
|
||||
|
||||
// Export buttons
|
||||
$('dg-export-csv').addEventListener('click', () => exportDeck('csv'));
|
||||
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
|
||||
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
|
||||
$('dg-export-shopping').addEventListener('click', () => exportShopping());
|
||||
|
||||
// Omit cards chips input
|
||||
const omitInput = $('dg-omit-input');
|
||||
if (omitInput) {
|
||||
omitInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addOmitCard(omitInput.value);
|
||||
omitInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
const omitWrap = $('dg-omit-wrap');
|
||||
if (omitWrap) {
|
||||
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function checkQueryParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const name = params.get('commander');
|
||||
if (name) {
|
||||
$('dg-commander-input').value = name;
|
||||
searchCommander(name);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build Request Body ──────────────────────────────────
|
||||
function buildRequestBody() {
|
||||
const budget = parseFloat($('dg-budget').value) || null;
|
||||
return {
|
||||
commander_name: state.commander.name,
|
||||
strategy: $('dg-strategy').value.trim(),
|
||||
target_bracket: state.targetBracket,
|
||||
budget_usd: budget,
|
||||
budget_mode: $('dg-budget-mode').value,
|
||||
omit_cards: omitCards.slice(),
|
||||
use_collection: $('dg-use-collection').checked,
|
||||
run_substitution: $('dg-substitution').checked,
|
||||
model: $('dg-model').value,
|
||||
deck_name: $('dg-deck-name').value.trim() || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ── Commander Search ────────────────────────────────────
|
||||
function positionDropdown() {
|
||||
const input = $('dg-commander-input');
|
||||
const dropdown = $('dg-commander-dropdown');
|
||||
const rect = input.getBoundingClientRect();
|
||||
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||
dropdown.style.left = rect.left + 'px';
|
||||
dropdown.style.width = rect.width + 'px';
|
||||
}
|
||||
|
||||
async function searchCommander(q) {
|
||||
const dropdown = $('dg-commander-dropdown');
|
||||
dropdown.innerHTML = '<div class="dg-dropdown-loading">Searching...</div>';
|
||||
dropdown.classList.add('open');
|
||||
positionDropdown();
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q));
|
||||
const data = await resp.json();
|
||||
renderDropdown(data.results || []);
|
||||
} catch (e) {
|
||||
dropdown.innerHTML = '<div class="dg-dropdown-empty">Search failed</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDropdown(results) {
|
||||
const dropdown = $('dg-commander-dropdown');
|
||||
|
||||
if (results.length === 0) {
|
||||
dropdown.innerHTML = '<div class="dg-dropdown-empty">No legendary creatures found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
dropdown.innerHTML = results.map((r, idx) => {
|
||||
const colors = (r.color_identity || [])
|
||||
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
|
||||
.join('');
|
||||
|
||||
const badge = r.in_collection
|
||||
? '<span class="dg-dropdown-item-badge dg-badge-owned">Owned</span>'
|
||||
: '<span class="dg-dropdown-item-badge dg-badge-scryfall">Scryfall</span>';
|
||||
|
||||
const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
|
||||
const imgTag = imgSrc
|
||||
? '<img class="dg-dropdown-item-img" src="' + imgSrc + '" alt="" loading="lazy" />'
|
||||
: '<div class="dg-dropdown-item-img" style="background:var(--lab-surface)"></div>';
|
||||
|
||||
return '<div class="dg-dropdown-item" data-idx="' + idx + '">'
|
||||
+ imgTag
|
||||
+ '<div class="dg-dropdown-item-info">'
|
||||
+ ' <div class="dg-dropdown-item-name">' + escHtml(r.name) + '</div>'
|
||||
+ ' <div class="dg-dropdown-item-type">' + escHtml(r.type_line || '') + '</div>'
|
||||
+ '</div>'
|
||||
+ '<div style="display:flex;gap:3px;align-items:center">' + colors + '</div>'
|
||||
+ badge
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
selectCommander(results[parseInt(el.dataset.idx, 10)]);
|
||||
hideDropdown();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function selectCommander(cmdr) {
|
||||
state.commander = cmdr;
|
||||
$('dg-commander-input').style.display = 'none';
|
||||
$('dg-commander-selected').style.display = 'block';
|
||||
$('dg-commander-name').textContent = cmdr.name;
|
||||
$('dg-commander-type').textContent = cmdr.type_line || '';
|
||||
|
||||
$('dg-commander-colors').innerHTML = (cmdr.color_identity || [])
|
||||
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
|
||||
.join('');
|
||||
|
||||
const img = $('dg-commander-img');
|
||||
if (cmdr.image_url) {
|
||||
var imgUrl = cmdr.image_url;
|
||||
if (imgUrl.indexOf('version=small') !== -1) {
|
||||
imgUrl = imgUrl.replace('version=small', 'version=normal');
|
||||
} else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) {
|
||||
imgUrl += '&version=normal';
|
||||
} else if (imgUrl.indexOf('version=') === -1) {
|
||||
imgUrl += '?version=normal';
|
||||
}
|
||||
img.src = imgUrl;
|
||||
img.style.display = 'block';
|
||||
} else {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
|
||||
$('dg-preview-btn').disabled = false;
|
||||
|
||||
// Auto-fill deck name if empty
|
||||
const deckNameInput = $('dg-deck-name');
|
||||
if (deckNameInput && !deckNameInput.value.trim()) {
|
||||
deckNameInput.value = 'Auto - ' + cmdr.name;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCommander() {
|
||||
state.commander = null;
|
||||
$('dg-commander-input').style.display = '';
|
||||
$('dg-commander-input').value = '';
|
||||
$('dg-commander-selected').style.display = 'none';
|
||||
$('dg-preview-btn').disabled = true;
|
||||
}
|
||||
|
||||
function hideDropdown() {
|
||||
$('dg-commander-dropdown').classList.remove('open');
|
||||
}
|
||||
|
||||
// -- Conversation Log Helper --
|
||||
function logMessage(msg, type = 'info') {
|
||||
const log = $('dg-conversation-log');
|
||||
if (!log) return;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'dg-log-entry dg-log-' + type;
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
|
||||
log.appendChild(entry);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
const log = $('dg-conversation-log');
|
||||
if (!log) return;
|
||||
log.innerHTML = '';
|
||||
}
|
||||
|
||||
// ── Generate Deck ───────────────────────────────────────
|
||||
async function generateDeck() {
|
||||
clearLog();
|
||||
logMessage('Connecting to Claude AI...', 'info');
|
||||
if (!state.commander) { toast('Select a commander first', 'error'); return; }
|
||||
// Non-blocking warning only — local Ollama mode does not require v3Ready
|
||||
if (!state.v3Ready) {
|
||||
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
|
||||
}
|
||||
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
|
||||
|
||||
setLoading(true, 'Generating deck via Local AI...');
|
||||
startProgress();
|
||||
|
||||
const body = buildRequestBody();
|
||||
logMessage('Sending request to AI model... this may take a moment.', 'info');
|
||||
state.lastRequestBody = body;
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/deck/v3/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.detail || 'Generation failed');
|
||||
}
|
||||
|
||||
state.previewResult = await resp.json();
|
||||
logMessage('AI response received! Processing deck data...', 'success');
|
||||
|
||||
const stats = state.previewResult.stats || {};
|
||||
const cardCount = (stats.total_cards || 0);
|
||||
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
|
||||
stopProgress(true);
|
||||
renderResults(state.previewResult);
|
||||
|
||||
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
||||
} catch (e) {
|
||||
stopProgress(false);
|
||||
toast(e.message || 'Generation failed', 'error');
|
||||
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(loading, text) {
|
||||
state.isLoading = loading;
|
||||
$('dg-empty-state').style.display = 'none';
|
||||
$('dg-loading').style.display = loading ? 'flex' : 'none';
|
||||
$('dg-results').style.display = loading ? 'none' : 'block';
|
||||
$('dg-preview-btn').disabled = loading || !state.commander;
|
||||
if (text) $('dg-loading-text').textContent = text;
|
||||
}
|
||||
|
||||
// ── Render Results ──────────────────────────────────────
|
||||
function renderResults(result) {
|
||||
$('dg-loading').style.display = 'none';
|
||||
$('dg-results').style.display = 'block';
|
||||
$('dg-preview-btn').disabled = false;
|
||||
|
||||
renderStrategyBar(result);
|
||||
renderStatsBar(result);
|
||||
renderSubstitutionSummary(result);
|
||||
renderCardGroups(result);
|
||||
renderUseInGameButton(result);
|
||||
}
|
||||
|
||||
function renderUseInGameButton(result) {
|
||||
// Only show when running inside the LAN client iframe
|
||||
if (window.parent === window) return;
|
||||
|
||||
var existing = document.getElementById('dg-export-game-btn');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var btn = document.createElement('button');
|
||||
btn.id = 'dg-export-game-btn';
|
||||
btn.className = 'dg-btn dg-btn-preview';
|
||||
btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);';
|
||||
btn.innerHTML = '<span class="dg-btn-icon">🎮</span> Use in Game';
|
||||
btn.addEventListener('click', function() {
|
||||
var cards = (result.cards || []).map(function(c) {
|
||||
return { name: c.name, quantity: c.count || 1, is_commander: false };
|
||||
});
|
||||
if (result.commander) {
|
||||
cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true });
|
||||
}
|
||||
window.parent.postMessage({
|
||||
type: 'DECK_BUILDER_EXPORT',
|
||||
deck: cards,
|
||||
deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck',
|
||||
commander: result.commander ? result.commander.name : '',
|
||||
}, '*');
|
||||
toast('Deck sent to game setup!', 'success');
|
||||
});
|
||||
|
||||
$('dg-results').appendChild(btn);
|
||||
}
|
||||
|
||||
function renderStrategyBar(result) {
|
||||
const bar = $('dg-strategy-bar');
|
||||
if (!bar) return;
|
||||
const bracket = result.bracket || {};
|
||||
const archetype = result.archetype || '';
|
||||
const strategy = result.strategy_summary || '';
|
||||
|
||||
bar.innerHTML = '<div class="dg-strategy-info">'
|
||||
+ '<span class="dg-bracket-badge dg-bracket-' + (bracket.level || 0) + '">B' + (bracket.level || '?') + '</span>'
|
||||
+ '<span class="dg-archetype-badge">' + escHtml(archetype) + '</span>'
|
||||
+ '<span class="dg-strategy-text">' + escHtml(strategy) + '</span>'
|
||||
+ '</div>'
|
||||
+ (bracket.reasoning ? '<div class="dg-bracket-reasoning">' + escHtml(bracket.reasoning) + '</div>' : '')
|
||||
+ (bracket.game_changers && bracket.game_changers.length
|
||||
? '<div class="dg-game-changers">Game Changers: ' + bracket.game_changers.map(c => '<span class="dg-gc-chip">' + escHtml(c) + '</span>').join('') + '</div>'
|
||||
: '');
|
||||
}
|
||||
|
||||
function renderStatsBar(result) {
|
||||
const stats = result.stats || {};
|
||||
const container = $('dg-stats-bar');
|
||||
if (!container) return;
|
||||
|
||||
const chips = [
|
||||
chipHtml('Total', stats.total_cards || 0, ''),
|
||||
chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'),
|
||||
chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'),
|
||||
chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'),
|
||||
chipHtml('Lands', stats.land_count || 0, ''),
|
||||
chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'),
|
||||
];
|
||||
|
||||
container.innerHTML = chips.join('');
|
||||
}
|
||||
|
||||
function chipHtml(label, num, cls) {
|
||||
return '<div class="dg-stat-chip ' + cls + '">'
|
||||
+ '<span class="dg-stat-num">' + num + '</span>'
|
||||
+ '<span class="dg-stat-label">' + label + '</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderSubstitutionSummary(result) {
|
||||
const el = $('dg-sub-summary');
|
||||
const sub = result.substitution_stats;
|
||||
if (!sub) { el.style.display = 'none'; return; }
|
||||
|
||||
el.style.display = 'block';
|
||||
el.innerHTML = '<div class="dg-sub-bar">'
|
||||
+ '<span class="dg-sub-stat dg-sub-owned">' + sub.owned + ' owned</span>'
|
||||
+ '<span class="dg-sub-stat dg-sub-substituted">' + sub.substituted + ' substituted</span>'
|
||||
+ '<span class="dg-sub-stat dg-sub-missing">' + sub.missing + ' still missing</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderCardGroups(result) {
|
||||
const container = $('dg-card-groups');
|
||||
if (!container) return;
|
||||
const cards = result.cards || [];
|
||||
const commander = result.commander;
|
||||
|
||||
const groups = {};
|
||||
for (const card of cards) {
|
||||
const cat = card.category || 'Other';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(card);
|
||||
}
|
||||
|
||||
const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0);
|
||||
for (const t of Object.keys(groups)) {
|
||||
if (!sortedTypes.includes(t)) sortedTypes.push(t);
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
if (commander) {
|
||||
html += '<div class="dg-card-group">'
|
||||
+ '<div class="dg-group-header">'
|
||||
+ '<span class="dg-group-title">⚔ Commander</span>'
|
||||
+ '<span class="dg-group-count">1</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="dg-group-body">'
|
||||
+ '<div class="dg-card-row dg-card-row-commander">'
|
||||
+ '<span class="dg-card-row-name" style="font-weight:600;color:#fff">' + escHtml(commander.name) + '</span>'
|
||||
+ '<span class="dg-card-row-source dg-src-collection">Commander</span>'
|
||||
+ '</div>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
for (const type of sortedTypes) {
|
||||
const groupCards = groups[type];
|
||||
if (!groupCards || groupCards.length === 0) continue;
|
||||
|
||||
groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
|
||||
const icon = TYPE_ICONS[type] || '🃏';
|
||||
html += '<div class="dg-card-group">'
|
||||
+ '<div class="dg-group-header" data-type="' + type + '">'
|
||||
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
|
||||
+ '<span class="dg-group-count">' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="dg-group-body" data-group="' + type + '">';
|
||||
|
||||
for (const card of groupCards) {
|
||||
html += renderCardRow(card);
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
container.querySelectorAll('.dg-group-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const type = header.dataset.type;
|
||||
if (!type) return;
|
||||
const body = container.querySelector('.dg-group-body[data-group="' + type + '"]');
|
||||
if (body) body.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderCardRow(card) {
|
||||
const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing;
|
||||
const statusBadge = '<span class="dg-card-status ' + statusInfo.cls + '">' + statusInfo.label + '</span>';
|
||||
|
||||
const roleTags = (card.role_tags || []).slice(0, 3)
|
||||
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
|
||||
.join('');
|
||||
|
||||
let subHtml = '';
|
||||
if (card.status === 'substituted' && card.selected_substitute) {
|
||||
subHtml = '<div class="dg-card-sub-line">'
|
||||
+ '<span class="dg-sub-arrow">→</span> '
|
||||
+ '<span class="dg-sub-name">' + escHtml(card.selected_substitute) + '</span>'
|
||||
+ '</div>';
|
||||
} else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
|
||||
subHtml = '<div class="dg-card-sub-line dg-sub-suggestions">'
|
||||
+ '<span class="dg-sub-label">Alternatives:</span> '
|
||||
+ card.alternatives.slice(0, 3).map(a =>
|
||||
'<span class="dg-alt-chip" title="' + escAttr(a.reason || '') + '">'
|
||||
+ escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
|
||||
+ '</span>'
|
||||
).join('')
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
const price = card.estimated_price_usd
|
||||
? '<span class="dg-card-price">$' + card.estimated_price_usd.toFixed(2) + '</span>'
|
||||
: '';
|
||||
|
||||
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
|
||||
+ '<div class="dg-card-row-main">'
|
||||
+ '<span class="dg-card-row-name">' + ((card.count || 1) > 1 ? '<span class="dg-card-count">' + card.count + 'x</span> ' : '') + escHtml(card.name) + '</span>'
|
||||
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
|
||||
+ statusBadge
|
||||
+ price
|
||||
+ '</div>'
|
||||
+ (card.reason ? '<div class="dg-card-reason">' + escHtml(card.reason) + '</div>' : '')
|
||||
+ subHtml
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// ── Commit Deck ─────────────────────────────────────────
|
||||
async function commitDeck() {
|
||||
if (!state.commander) { toast('No commander selected', 'error'); return; }
|
||||
|
||||
$('dg-save-btn').disabled = true;
|
||||
$('dg-save-btn').textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const body = state.lastRequestBody || buildRequestBody();
|
||||
const resp = await fetch(API_BASE + '/api/deck/v3/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.detail || 'Save failed');
|
||||
}
|
||||
|
||||
const result = await resp.json();
|
||||
toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id;
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Save failed', 'error');
|
||||
} finally {
|
||||
$('dg-save-btn').disabled = false;
|
||||
$('dg-save-btn').innerHTML = '<span class="dg-btn-icon">💾</span> Save to Deck Builder';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────────────────
|
||||
async function exportDeck(format) {
|
||||
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
|
||||
|
||||
const body = state.lastRequestBody || buildRequestBody();
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error('Export failed');
|
||||
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck');
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast('Exported ' + format.toUpperCase(), 'success');
|
||||
} catch (e) {
|
||||
toast(e.message || 'Export failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportMoxfield() {
|
||||
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
|
||||
|
||||
const body = state.lastRequestBody || buildRequestBody();
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
if (data.content) {
|
||||
await navigator.clipboard.writeText(data.content);
|
||||
toast('Moxfield deck copied to clipboard', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
toast('Moxfield export failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportShopping() {
|
||||
if (!state.commander) { toast('Generate a deck first', 'error'); return; }
|
||||
|
||||
const body = state.lastRequestBody || buildRequestBody();
|
||||
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
const list = data.shopping_list || [];
|
||||
|
||||
if (list.length === 0) {
|
||||
toast('All cards are owned — nothing to buy', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
|
||||
for (const item of list) {
|
||||
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
|
||||
}
|
||||
lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2));
|
||||
|
||||
await navigator.clipboard.writeText(lines.join('\n'));
|
||||
toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success');
|
||||
} catch (e) {
|
||||
toast('Shopping list failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toast ───────────────────────────────────────────────
|
||||
function toast(msg, type) {
|
||||
type = type || 'info';
|
||||
const container = $('dg-toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'dg-toast dg-toast-' + type;
|
||||
el.textContent = msg;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────
|
||||
function escHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(str) {
|
||||
return str.replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
DeckGenerator.init();
|
||||
});
|
||||
-25735
File diff suppressed because it is too large.
Load diff
@@ -1,955 +0,0 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Commander AI Lab — Global Design System
|
||||
Dark & Polished · Premium Dashboard Aesthetic
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ── Design Tokens ──────────────────────────────────────── */
|
||||
:root {
|
||||
/* Surfaces — layered dark with subtle warmth */
|
||||
--bg: #0c0e14;
|
||||
--surface-0: #12141c;
|
||||
--surface-1: #171a24;
|
||||
--surface-2: #1c1f2b;
|
||||
--surface-3: #222533;
|
||||
--surface-hover: #252839;
|
||||
|
||||
/* Borders */
|
||||
--border: #2a2d3a;
|
||||
--border-subtle: #1e2130;
|
||||
--border-focus: #4a6fa5;
|
||||
|
||||
/* Text */
|
||||
--text: #e1e4eb;
|
||||
--text-secondary:#8b90a0;
|
||||
--text-tertiary: #5c6070;
|
||||
--text-inverse: #0c0e14;
|
||||
|
||||
/* Accent — cool blue with subtle depth */
|
||||
--accent: #5b9ef0;
|
||||
--accent-hover: #7ab4ff;
|
||||
--accent-dim: rgba(91, 158, 240, 0.12);
|
||||
--accent-glow: rgba(91, 158, 240, 0.25);
|
||||
|
||||
/* Semantic */
|
||||
--success: #4ade80;
|
||||
--success-dim: rgba(74, 222, 128, 0.12);
|
||||
--warning: #fbbf24;
|
||||
--warning-dim: rgba(251, 191, 36, 0.12);
|
||||
--error: #f87171;
|
||||
--error-dim: rgba(248, 113, 113, 0.12);
|
||||
--purple: #a78bfa;
|
||||
--purple-dim: rgba(167, 139, 250, 0.12);
|
||||
--gold: #fcd34d;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.35);
|
||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.45);
|
||||
--shadow-glow: 0 0 20px var(--accent-dim);
|
||||
|
||||
/* Transitions */
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--duration: 180ms;
|
||||
|
||||
/* Typography */
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
/* Spacing scale */
|
||||
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px;
|
||||
--sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
|
||||
--sp-8: 32px; --sp-10: 40px; --sp-12: 48px;
|
||||
--sp-16: 64px;
|
||||
|
||||
/* Layout */
|
||||
--content-max: 1280px;
|
||||
--sidebar-width: 280px;
|
||||
--nav-height: 56px;
|
||||
}
|
||||
|
||||
/* ── Base ───────────────────────────────────────────────── */
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h1 { font-size: 24px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 16px; }
|
||||
|
||||
a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); }
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
img, svg { display: block; max-width: 100%; }
|
||||
button { cursor: pointer; font: inherit; }
|
||||
input, select, textarea { font: inherit; color: inherit; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
|
||||
::selection {
|
||||
background: var(--accent-dim);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Scrollbar ──────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||
|
||||
/* ── Navigation ─────────────────────────────────────────── */
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--nav-height);
|
||||
padding: 0 var(--sp-6);
|
||||
background: rgba(12, 14, 20, 0.85);
|
||||
backdrop-filter: blur(16px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.5);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: -0.01em;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
margin-right: var(--sp-8);
|
||||
}
|
||||
|
||||
.nav-brand svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav-links::-webkit-scrollbar { display: none; }
|
||||
|
||||
.nav-links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: all var(--duration) var(--ease);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.nav-links a.active {
|
||||
color: #fff;
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.nav-links a .nav-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nav-links a.active .nav-icon,
|
||||
.nav-links a:hover .nav-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Page Layout ────────────────────────────────────────── */
|
||||
.page {
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-8) var(--sp-6);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--sp-8);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Cards / Panels ─────────────────────────────────────── */
|
||||
.panel {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-6);
|
||||
margin-bottom: var(--sp-6);
|
||||
transition: border-color var(--duration) var(--ease);
|
||||
}
|
||||
|
||||
.panel:hover {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-header .badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Stat Cards ─────────────────────────────────────────── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
margin-bottom: var(--sp-6);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-5);
|
||||
transition: all var(--duration) var(--ease);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--border-focus);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 8px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration) var(--ease);
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 4px 14px var(--accent-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.btn-success { background: var(--success); color: var(--text-inverse); }
|
||||
.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); }
|
||||
|
||||
.btn-purple { background: var(--purple); color: var(--text-inverse); }
|
||||
.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); }
|
||||
|
||||
.btn-danger { background: var(--error); color: #fff; }
|
||||
.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); }
|
||||
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
.btn-lg { padding: 12px 24px; font-size: 15px; }
|
||||
.btn-icon-only { padding: 8px; width: 36px; height: 36px; }
|
||||
|
||||
/* ── Forms ──────────────────────────────────────────────── */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--duration) var(--ease);
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input::placeholder,
|
||||
.form-textarea::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
/* ── Progress Bar ───────────────────────────────────────── */
|
||||
.progress-container {
|
||||
margin-top: var(--sp-4);
|
||||
padding: var(--sp-4);
|
||||
background: var(--surface-0);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
background: var(--surface-3);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s var(--ease);
|
||||
}
|
||||
|
||||
.progress-detail {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Phase Badge ────────────────────────────────────────── */
|
||||
.badge-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge-status::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-idle { background: var(--surface-3); color: var(--text-secondary); }
|
||||
.badge-idle::before { background: var(--text-tertiary); }
|
||||
|
||||
.badge-running { background: var(--accent-dim); color: var(--accent); }
|
||||
.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||
|
||||
.badge-success { background: var(--success-dim); color: var(--success); }
|
||||
.badge-success::before { background: var(--success); }
|
||||
|
||||
.badge-error { background: var(--error-dim); color: var(--error); }
|
||||
.badge-error::before { background: var(--error); }
|
||||
|
||||
.badge-warning { background: var(--warning-dim); color: var(--warning); }
|
||||
.badge-warning::before { background: var(--warning); }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── Empty State ────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--sp-12) var(--sp-6);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--sp-4);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
/* ── Tables ─────────────────────────────────────────────── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.data-table thead th {
|
||||
text-align: left;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
.data-table tbody td {
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.data-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ── Tags ───────────────────────────────────────────────── */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-blue { background: var(--accent-dim); color: var(--accent); }
|
||||
.tag-green { background: var(--success-dim); color: var(--success); }
|
||||
.tag-yellow { background: var(--warning-dim); color: var(--warning); }
|
||||
.tag-red { background: var(--error-dim); color: var(--error); }
|
||||
.tag-purple { background: var(--purple-dim); color: var(--purple); }
|
||||
|
||||
/* ── Toast / Notification ───────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--sp-6);
|
||||
right: var(--sp-6);
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
z-index: 1000;
|
||||
animation: toast-in 0.3s var(--ease);
|
||||
}
|
||||
|
||||
.toast-success { border-left: 3px solid var(--success); }
|
||||
.toast-error { border-left: 3px solid var(--error); }
|
||||
.toast-warning { border-left: 3px solid var(--warning); }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Loading Spinner ────────────────────────────────────── */
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Divider ────────────────────────────────────────────── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: var(--sp-5) 0;
|
||||
}
|
||||
|
||||
/* ── Utility Classes ────────────────────────────────────── */
|
||||
.hidden { display: none !important; }
|
||||
.text-accent { color: var(--accent); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-error { color: var(--error); }
|
||||
.text-muted { color: var(--text-secondary); }
|
||||
.text-faint { color: var(--text-tertiary); }
|
||||
.text-mono { font-family: var(--font-mono); }
|
||||
.text-sm { font-size: 12px; }
|
||||
.text-xs { font-size: 11px; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.mt-2 { margin-top: var(--sp-2); }
|
||||
.mt-4 { margin-top: var(--sp-4); }
|
||||
.mt-6 { margin-top: var(--sp-6); }
|
||||
.mb-2 { margin-bottom: var(--sp-2); }
|
||||
.mb-4 { margin-bottom: var(--sp-4); }
|
||||
.mb-6 { margin-bottom: var(--sp-6); }
|
||||
.gap-2 { gap: var(--sp-2); }
|
||||
.gap-4 { gap: var(--sp-4); }
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.flex-1 { flex: 1; }
|
||||
.grid { display: grid; }
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.page {
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
margin-right: var(--sp-4);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
PREMIUM UPGRADE LAYER
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Ambient background glow (body) ─────────────────────── */
|
||||
body {
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* ── Page title: subtle gradient text ───────────────────── */
|
||||
.page-header h1 {
|
||||
background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ── Nav: stronger glass + accent underline ─────────────── */
|
||||
.nav {
|
||||
background: rgba(9,11,16,0.80);
|
||||
backdrop-filter: blur(20px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.8);
|
||||
box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
|
||||
border-bottom-color: rgba(255,255,255,0.06);
|
||||
}
|
||||
.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
|
||||
.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
|
||||
|
||||
/* ── Panels: gradient border on hover ───────────────────── */
|
||||
.panel {
|
||||
position: relative;
|
||||
transition: border-color var(--duration) var(--ease),
|
||||
box-shadow var(--duration) var(--ease);
|
||||
}
|
||||
.panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration) var(--ease);
|
||||
}
|
||||
.panel:hover::before { opacity: 1; }
|
||||
.panel:hover {
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
|
||||
}
|
||||
|
||||
/* ── Stat cards: gradient surface + shimmer top edge ─────── */
|
||||
.stat-card {
|
||||
position: relative;
|
||||
background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Buttons: gradient fills + inner highlight ───────────── */
|
||||
.btn { position: relative; overflow: hidden; }
|
||||
.btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), #4a8de0);
|
||||
box-shadow: 0 2px 8px rgba(91,158,240,0.25);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
|
||||
box-shadow: 0 4px 20px rgba(91,158,240,0.4);
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success), #38c46c);
|
||||
box-shadow: 0 2px 8px rgba(74,222,128,0.2);
|
||||
}
|
||||
.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
|
||||
.btn-purple {
|
||||
background: linear-gradient(135deg, var(--purple), #9171f0);
|
||||
box-shadow: 0 2px 8px rgba(167,139,250,0.2);
|
||||
}
|
||||
.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
|
||||
|
||||
/* ── Tags: subtle colored border ────────────────────────── */
|
||||
.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
|
||||
.tag-green { border: 1px solid rgba(74,222,128,0.25); }
|
||||
.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
|
||||
.tag-red { border: 1px solid rgba(248,113,113,0.25); }
|
||||
.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
|
||||
|
||||
/* ── Progress fill: glow ─────────────────────────────────── */
|
||||
.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
|
||||
|
||||
/* ── Badge running: glow dot ─────────────────────────────── */
|
||||
.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
|
||||
.badge-running::before { box-shadow: 0 0 6px var(--accent); }
|
||||
|
||||
/* ── Toast: glassmorphism + colored glow ─────────────────── */
|
||||
.toast { backdrop-filter: blur(12px); }
|
||||
.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
|
||||
.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
|
||||
|
||||
/* ── Divider: fade-out gradient ──────────────────────────── */
|
||||
.divider {
|
||||
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
||||
}
|
||||
|
||||
/* ── Inputs: inset shadow for depth ─────────────────────── */
|
||||
.form-input, .form-select, .form-textarea {
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
Tooltip System (.tip-icon + data-tip)
|
||||
Shared across all pages — do not duplicate in page-specific CSS
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.tip-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tip-icon:hover {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tip-icon::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-0, #1a1a2e);
|
||||
color: var(--text, #e0e0e0);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: var(--radius-md, 6px);
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
width: 240px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1000;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tip-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: calc(100% + 3px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--border, #333);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.tip-icon:hover::after,
|
||||
.tip-icon:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tip-icon.tip-below::after {
|
||||
bottom: auto;
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.tip-icon.tip-below::before {
|
||||
bottom: auto;
|
||||
top: calc(100% + 3px);
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--border, #333);
|
||||
}
|
||||
@@ -1,944 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MTG Commander Battlefield</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260403_0300">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ====================================================
|
||||
SETUP SCREEN
|
||||
==================================================== -->
|
||||
<div id="setup-screen">
|
||||
<!-- Populated by ui.js initSetupScreen() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
TABLE SURFACE — 4-Quadrant Battlefield
|
||||
==================================================== -->
|
||||
<div class="table-surface" id="game-battlefield" style="display:none;">
|
||||
|
||||
<!-- TOP BAR: Phase tracker + controls -->
|
||||
<div id="top-bar">
|
||||
<span class="top-bar-logo">Commander</span>
|
||||
|
||||
<div class="turn-counter-badge" id="turn-counter">Turn <span>1</span></div>
|
||||
|
||||
<div class="active-player-indicator" id="active-player-indicator">
|
||||
<span class="active-player-dot" id="active-dot" style="background:#c0392b;"></span>
|
||||
Player 1's Turn
|
||||
</div>
|
||||
|
||||
<!-- Phase track -->
|
||||
<div class="phase-track-wrapper">
|
||||
<div class="phase-track" id="phase-track"></div>
|
||||
</div>
|
||||
|
||||
<!-- Phase/turn action buttons -->
|
||||
<div class="top-bar-actions">
|
||||
<button class="btn-phase" onclick="nextPhase()" title="Advance to next phase (N)">Next Phase</button>
|
||||
<button class="btn-phase btn-next-turn" onclick="nextTurn()" title="End turn, advance to next player (T)">Next Turn</button>
|
||||
</div>
|
||||
|
||||
<!-- Utility buttons -->
|
||||
<div class="top-bar-utils">
|
||||
<button class="btn-util" id="btn-undo" onclick="undo()" title="Undo (Ctrl+Z)" disabled>↩</button>
|
||||
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>↪</button>
|
||||
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">❇</button>
|
||||
<button class="btn-util" onclick="openDiceModal()" title="Dice & Coins (G)">🎲</button>
|
||||
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
|
||||
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">⚙</button>
|
||||
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">🔍</button>
|
||||
<button class="btn-util" id="btn-deckbuilder-toggle" onclick="toggleDeckBuilderPanel()" title="Deck Builder">🃏</button>
|
||||
<button class="btn-util btn-util-exit" onclick="location.reload()" title="Return to Start Screen">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Storm counter -->
|
||||
<span id="storm-badge" style="display:none;font-size:10px;color:var(--mtg-gold);background:rgba(200,168,67,0.15);border:1px solid rgba(200,168,67,0.3);border-radius:4px;padding:2px 7px;white-space:nowrap;">Storm: 0</span>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER LINES -->
|
||||
<div class="divider-h"></div>
|
||||
<div class="divider-v"></div>
|
||||
|
||||
<!-- CORNER ACCENTS -->
|
||||
<div class="corner-accent tl"></div>
|
||||
<div class="corner-accent tr"></div>
|
||||
<div class="corner-accent bl"></div>
|
||||
<div class="corner-accent br"></div>
|
||||
|
||||
|
||||
<!-- ================================================
|
||||
4-QUADRANT BATTLEFIELD
|
||||
================================================ -->
|
||||
<div class="battlefield">
|
||||
|
||||
<!-- PLAYER 1: Top-Left (Crimson) -->
|
||||
<div class="player-area p1" id="player-area-1">
|
||||
<span class="player-label" id="player-label-1">Player 1</span>
|
||||
|
||||
<!-- Zone Row: Library, Exile, Graveyard, Hand -->
|
||||
<div class="zone-row" id="zone-row-1">
|
||||
<div class="zone library" id="lib-zone-1" data-player="1" data-zone="library"
|
||||
onclick="openZoneModal(0,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-1">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-1" data-player="1" data-zone="exile"
|
||||
onclick="openZoneModal(0,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-1" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-1" data-player="1" data-zone="graveyard"
|
||||
onclick="openZoneModal(0,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-1" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-1" data-player="1" data-zone="hand"
|
||||
onclick="openZoneModal(0,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-1">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commander Row -->
|
||||
<div class="commander-row" id="cmd-row-1">
|
||||
<div class="zone commander" id="cmd-zone-1" data-player="1" data-zone="command"
|
||||
onclick="openZoneModal(0,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Battle Zone -->
|
||||
<div class="battle-zone" id="battle-zone-1">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
<!-- Battlefield cards injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 2: Top-Right (Sapphire) -->
|
||||
<div class="player-area p2" id="player-area-2">
|
||||
<span class="player-label" id="player-label-2">Player 2</span>
|
||||
|
||||
<div class="zone-row" id="zone-row-2">
|
||||
<div class="zone library" id="lib-zone-2" data-player="2" data-zone="library"
|
||||
onclick="openZoneModal(1,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-2">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-2" data-player="2" data-zone="exile"
|
||||
onclick="openZoneModal(1,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-2" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-2" data-player="2" data-zone="graveyard"
|
||||
onclick="openZoneModal(1,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-2" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-2" data-player="2" data-zone="hand"
|
||||
onclick="openZoneModal(1,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-2">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commander-row" id="cmd-row-2">
|
||||
<div class="zone commander" id="cmd-zone-2" data-player="2" data-zone="command"
|
||||
onclick="openZoneModal(1,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="battle-zone" id="battle-zone-2">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 3: Bottom-Left (Emerald) -->
|
||||
<div class="player-area p3" id="player-area-3">
|
||||
<!-- Battle zone on top for bottom players -->
|
||||
<div class="battle-zone" id="battle-zone-3">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
</div>
|
||||
|
||||
<div class="commander-row" id="cmd-row-3">
|
||||
<div class="zone commander" id="cmd-zone-3" data-player="3" data-zone="command"
|
||||
onclick="openZoneModal(2,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-row" id="zone-row-3">
|
||||
<div class="zone library" id="lib-zone-3" data-player="3" data-zone="library"
|
||||
onclick="openZoneModal(2,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-3">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-3" data-player="3" data-zone="exile"
|
||||
onclick="openZoneModal(2,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-3" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-3" data-player="3" data-zone="graveyard"
|
||||
onclick="openZoneModal(2,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-3" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-3" data-player="3" data-zone="hand"
|
||||
onclick="openZoneModal(2,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-3">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="player-label" id="player-label-3">Player 3</span>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 4: Bottom-Right (Gold) -->
|
||||
<div class="player-area p4" id="player-area-4">
|
||||
<div class="battle-zone" id="battle-zone-4">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
</div>
|
||||
|
||||
<div class="commander-row" id="cmd-row-4">
|
||||
<div class="zone commander" id="cmd-zone-4" data-player="4" data-zone="command"
|
||||
onclick="openZoneModal(3,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-row" id="zone-row-4">
|
||||
<div class="zone library" id="lib-zone-4" data-player="4" data-zone="library"
|
||||
onclick="openZoneModal(3,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-4">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-4" data-player="4" data-zone="exile"
|
||||
onclick="openZoneModal(3,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-4" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-4" data-player="4" data-zone="graveyard"
|
||||
onclick="openZoneModal(3,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-4" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-4" data-player="4" data-zone="hand"
|
||||
onclick="openZoneModal(3,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-4">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="player-label" id="player-label-4">Player 4</span>
|
||||
</div>
|
||||
|
||||
</div><!-- /.battlefield -->
|
||||
|
||||
<!-- LIFE PANELS OVERLAY (top-most layer) -->
|
||||
<div id="life-panels-overlay">
|
||||
<div class="life-panel lp-p1" id="life-panel-1">
|
||||
<div class="life-panel-box life-box">
|
||||
<span class="life-panel-label">Life</span>
|
||||
<div class="life-counter" id="life-counter-1">
|
||||
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">−</button>
|
||||
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
|
||||
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-0" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||
<span class="life-panel-label">Cmd Dmg</span>
|
||||
<div class="cmd-dmg-inline-row">—</div>
|
||||
</div>
|
||||
<div class="life-panel-box counters-box">
|
||||
<span class="life-panel-label">Counters</span>
|
||||
<div class="poison-badge" id="poison-0" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="life-panel lp-p2" id="life-panel-2">
|
||||
<div class="life-panel-box life-box">
|
||||
<span class="life-panel-label">Life</span>
|
||||
<div class="life-counter" id="life-counter-2">
|
||||
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">−</button>
|
||||
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
|
||||
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-1" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||
<span class="life-panel-label">Cmd Dmg</span>
|
||||
<div class="cmd-dmg-inline-row">—</div>
|
||||
</div>
|
||||
<div class="life-panel-box counters-box">
|
||||
<span class="life-panel-label">Counters</span>
|
||||
<div class="poison-badge" id="poison-1" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="life-panel lp-p3" id="life-panel-3">
|
||||
<div class="life-panel-box life-box">
|
||||
<span class="life-panel-label">Life</span>
|
||||
<div class="life-counter" id="life-counter-3">
|
||||
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">−</button>
|
||||
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
|
||||
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-2" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||
<span class="life-panel-label">Cmd Dmg</span>
|
||||
<div class="cmd-dmg-inline-row">—</div>
|
||||
</div>
|
||||
<div class="life-panel-box counters-box">
|
||||
<span class="life-panel-label">Counters</span>
|
||||
<div class="poison-badge" id="poison-2" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="life-panel lp-p4" id="life-panel-4">
|
||||
<div class="life-panel-box life-box">
|
||||
<span class="life-panel-label">Life</span>
|
||||
<div class="life-counter" id="life-counter-4">
|
||||
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">−</button>
|
||||
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
|
||||
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="life-panel-box cmd-dmg-box" id="cmd-dmg-inline-3" onclick="openCmdDmgMatrix()" title="Open Commander Damage matrix">
|
||||
<span class="life-panel-label">Cmd Dmg</span>
|
||||
<div class="cmd-dmg-inline-row">—</div>
|
||||
</div>
|
||||
<div class="life-panel-box counters-box">
|
||||
<span class="life-panel-label">Counters</span>
|
||||
<div class="poison-badge" id="poison-3" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COMBAT SVG OVERLAY (arrows) -->
|
||||
<svg id="combat-overlay" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:44px 0 0 0;pointer-events:none;z-index:30;">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="rgba(211,32,42,0.7)"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<g id="combat-arrows-svg"></g>
|
||||
</svg>
|
||||
|
||||
<!-- FLOATING LOG -->
|
||||
<div id="floating-log" class="floating-log"></div>
|
||||
|
||||
<!-- SHORTCUT HINT -->
|
||||
<div class="shortcut-hint"></div>
|
||||
|
||||
<!-- CARD SEARCH SIDEBAR (right side, collapsible) -->
|
||||
<div id="card-search-sidebar" class="collapsed">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Card Search</span>
|
||||
<button class="sidebar-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Close search">✕</button>
|
||||
</div>
|
||||
<div class="search-input-wrap">
|
||||
<input class="search-input" id="card-search-input" type="text" placeholder="Search Scryfall..." autocomplete="off"/>
|
||||
</div>
|
||||
<div class="search-status" id="search-status">Type to search cards...</div>
|
||||
<div class="card-results" id="card-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar edge tab -->
|
||||
<div id="sidebar-edge-tab" class="sidebar-edge-tab" title="Toggle card search">‹</div>
|
||||
|
||||
<!-- LEFT ACTION PANEL (triggers + quick actions) -->
|
||||
<div id="left-action-panel" class="collapsed">
|
||||
<div class="left-panel-header">
|
||||
<span class="left-panel-title">Triggers <span id="left-panel-trigger-count" class="left-panel-count">0</span></span>
|
||||
<button class="sidebar-toggle" onclick="toggleLeftPanel()" title="Close panel">✕</button>
|
||||
</div>
|
||||
<div class="left-panel-actions">
|
||||
<button class="left-panel-btn" onclick="openTokenModal((typeof gameState !== 'undefined' && gameState) ? (gameState.currentPlayerIndex || 0) : 0)" title="Create token">✨ Create Token</button>
|
||||
<button class="left-panel-btn" onclick="autoArrangeBattlefield()" title="Auto-arrange battlefield">⇄ Auto-Arrange</button>
|
||||
</div>
|
||||
<div class="left-panel-body" id="left-panel-triggers">
|
||||
<div class="left-panel-empty">No pending triggers.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left panel edge tab -->
|
||||
<div id="left-panel-edge-tab" class="left-panel-edge-tab" onclick="toggleLeftPanel()" title="Toggle quick actions">›</div>
|
||||
|
||||
</div><!-- /.table-surface / #game-battlefield -->
|
||||
|
||||
<!-- ====================================================
|
||||
CONTEXT MENU
|
||||
==================================================== -->
|
||||
<div id="context-menu"></div>
|
||||
|
||||
<!-- ====================================================
|
||||
FLOATING CARD PREVIEW
|
||||
==================================================== -->
|
||||
<div id="card-preview">
|
||||
<img src="" alt="Card Preview"/>
|
||||
<div class="card-preview-pt-overlay" id="card-preview-pt-overlay"></div>
|
||||
<div class="card-preview-info">
|
||||
<div class="card-preview-name"></div>
|
||||
<div class="card-preview-type"></div>
|
||||
<div class="card-preview-oracle"></div>
|
||||
<div class="card-preview-pt"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
ZONE MODAL
|
||||
==================================================== -->
|
||||
<div id="zone-modal">
|
||||
<div class="zone-modal-card">
|
||||
<div class="zone-modal-header">
|
||||
<span class="zone-modal-title" id="zone-modal-title">Player Zones</span>
|
||||
<button class="btn-close" onclick="closeZoneModal()">✕</button>
|
||||
</div>
|
||||
<div class="zone-modal-tabs" id="zone-modal-tabs">
|
||||
<!-- Populated by renderZoneModal() -->
|
||||
</div>
|
||||
<div class="zone-modal-body" id="zone-modal-body">
|
||||
<!-- Populated by renderZoneModal() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
DICE MODAL
|
||||
==================================================== -->
|
||||
<div id="dice-modal">
|
||||
<div class="dice-modal-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<span style="font-family:var(--font-display);font-size:14px;font-weight:700;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.08em;">Dice & Coins</span>
|
||||
<button class="btn-close" onclick="closeDiceModal()">✕</button>
|
||||
</div>
|
||||
<div class="dice-result-display" id="dice-result-display">—</div>
|
||||
<div class="dice-label" id="dice-label">Choose a die</div>
|
||||
<div class="dice-buttons">
|
||||
<button class="btn-dice" onclick="rollDice(4)">d4</button>
|
||||
<button class="btn-dice" onclick="rollDice(6)">d6</button>
|
||||
<button class="btn-dice" onclick="rollDice(8)">d8</button>
|
||||
<button class="btn-dice" onclick="rollDice(10)">d10</button>
|
||||
<button class="btn-dice" onclick="rollDice(12)">d12</button>
|
||||
<button class="btn-dice" onclick="rollDice(20)">d20</button>
|
||||
<button class="btn-dice" onclick="rollDice(100)">d100</button>
|
||||
<button class="btn-dice coin" onclick="flipCoin()">Coin Flip</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
TOKEN MODAL
|
||||
==================================================== -->
|
||||
<div id="token-modal">
|
||||
<div class="token-modal-card">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Create Token</span>
|
||||
<button class="btn-close" onclick="closeTokenModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="token-modal-body">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px;">
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Token Name</label>
|
||||
<input class="game-input" id="token-name" type="text" placeholder="e.g. 1/1 White Soldier"/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Type Line</label>
|
||||
<input class="game-input" id="token-type" type="text" placeholder="Creature — Soldier"/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Power/Toughness</label>
|
||||
<input class="game-input" id="token-pt" type="text" placeholder="1/1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Count</label>
|
||||
<input class="game-input" id="token-count" type="number" value="1" min="1" max="20"/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Keywords (comma-separated)</label>
|
||||
<input class="game-input" id="token-keywords" type="text" placeholder="Flying, Vigilance"/>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Player</label>
|
||||
<select class="game-input" id="token-player-select" style="height:28px;">
|
||||
<option value="0">Player 1</option>
|
||||
<option value="1">Player 2</option>
|
||||
<option value="2">Player 3</option>
|
||||
<option value="3">Player 4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-primary" onclick="createTokenFromModal()">Create Token(s)</button>
|
||||
<button class="btn btn-cancel" onclick="closeTokenModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
DECK MODAL
|
||||
==================================================== -->
|
||||
<div id="deck-modal">
|
||||
<div class="deck-modal-card">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Deck Manager</span>
|
||||
<button class="btn-close" onclick="closeDeckModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="deck-modal-body">
|
||||
<!-- Populated by renderDeckModalContent() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
COMMANDER DAMAGE MATRIX MODAL
|
||||
==================================================== -->
|
||||
<div id="cmd-dmg-matrix-modal">
|
||||
<!-- Populated by renderCmdDmgMatrix() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
TRIGGER STACK PANEL
|
||||
==================================================== -->
|
||||
<div id="trigger-stack-panel">
|
||||
<div class="trigger-stack-card">
|
||||
<div class="trigger-stack-header">
|
||||
<span class="trigger-stack-title">Trigger Stack</span>
|
||||
<button class="btn-close" style="font-size:14px;width:20px;height:20px;" onclick="closeTriggerStack()">✕</button>
|
||||
</div>
|
||||
<div class="trigger-stack-body" id="trigger-stack-body">
|
||||
<div class="trigger-stack-empty">No pending triggers.</div>
|
||||
</div>
|
||||
<div class="trigger-stack-actions">
|
||||
<button class="btn-resolve" onclick="resolveNextTrigger()">Resolve Next</button>
|
||||
<button class="btn-resolve-all" onclick="resolveAllTriggers()">Resolve All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
SETTINGS PANEL
|
||||
==================================================== -->
|
||||
<div id="settings-panel">
|
||||
<div class="modal-header" style="flex-shrink:0;">
|
||||
<span class="modal-title">Settings</span>
|
||||
<button class="btn-close" onclick="toggleSettingsPanel()">✕</button>
|
||||
</div>
|
||||
<div class="settings-body" id="settings-body" style="flex:1;overflow-y:auto;padding:12px;">
|
||||
<!-- Populated by renderSettingsPanel() -->
|
||||
</div>
|
||||
<div style="padding:10px 14px;border-top:1px solid var(--color-border);display:flex;gap:8px;flex-wrap:wrap;flex-shrink:0;">
|
||||
<button class="btn btn-primary" onclick="openDiceModal()">🎲 Dice</button>
|
||||
<button class="btn btn-gold" onclick="openCmdDmgMatrix()">CMD Damage Matrix</button>
|
||||
<button class="btn btn-cancel" onclick="checkStateBasedActions()">Check SBA</button>
|
||||
<button class="btn btn-cancel" onclick="autoArrangeBattlefield()">Auto-Arrange</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
PRIORITY BANNER
|
||||
==================================================== -->
|
||||
<div id="priority-banner">
|
||||
<span class="priority-text" id="priority-text">Waiting for priority...</span>
|
||||
<button class="btn-priority-pass" onclick="passPriority()">Pass Priority</button>
|
||||
<button class="btn-close" style="font-size:13px;" onclick="closePriorityWindow()">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
LEGAL ACTIONS BAR
|
||||
==================================================== -->
|
||||
<div id="legal-actions-bar">
|
||||
<!-- Populated by renderLegalActionsBar() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
UNDO HISTORY PANEL
|
||||
==================================================== -->
|
||||
<div id="undo-history-panel">
|
||||
<!-- Populated by renderUndoHistory() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
MANA COLOR PICKER (dynamically appended) -->
|
||||
<!-- Zone card context menu (dynamically appended) -->
|
||||
<!-- SBA alert panel (dynamically appended) -->
|
||||
<!-- Set-life dialog (dynamically appended) -->
|
||||
|
||||
<!-- ====================================================
|
||||
SCRIPTS
|
||||
Load order matters:
|
||||
1. precons.js — static PRECON_DATA fallback
|
||||
2. engine.js — game state & core logic
|
||||
3. ai-bridge.js — backend connector & DeckSourceClient
|
||||
4. precon-loader.js — startGameWithDecks() + loadPreconDeckFromDck()
|
||||
5. player-panels.js — player panel rendering
|
||||
6. ui.js — setup screen, deck picker, UI wiring
|
||||
==================================================== -->
|
||||
<script src="precons.js?v=20260403_0300"></script>
|
||||
<script src="engine.js?v=20260403_0300"></script>
|
||||
<script src="ai-bridge.js?v=20260403_0300"></script>
|
||||
<script src="precon-loader.js?v=20260403_0300"></script>
|
||||
<script src="player-panels.js?v=20260403_0300"></script>
|
||||
<script src="ui.js?v=20260403_0300"></script>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// STARTUP & BRIDGE FUNCTIONS
|
||||
// ============================================================
|
||||
|
||||
// Token creation from modal
|
||||
function createTokenFromModal() {
|
||||
if (!gameState) return;
|
||||
const name = document.getElementById('token-name').value.trim() || '1/1 Token';
|
||||
const typeLine = document.getElementById('token-type').value.trim() || 'Creature';
|
||||
const pt = document.getElementById('token-pt').value.trim() || '1/1';
|
||||
const count = parseInt(document.getElementById('token-count').value) || 1;
|
||||
const keywords = document.getElementById('token-keywords').value.trim();
|
||||
const playerIdx = parseInt(document.getElementById('token-player-select').value) || 0;
|
||||
|
||||
const parts = pt.split('/');
|
||||
const power = parts[0] ? parts[0].trim() : '1';
|
||||
const toughness = parts[1] ? parts[1].trim() : '1';
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
addCardToBattlefield(name, '', typeLine, playerIdx, undefined, undefined,
|
||||
keywords ? keywords.replace(/,/g, ' ') : '',
|
||||
{ power, toughness, pt, isToken: true });
|
||||
}
|
||||
addLogEntry('Created ' + count + 'x <strong>' + name + '</strong> token(s) for Player ' + (playerIdx + 1));
|
||||
closeTokenModal();
|
||||
}
|
||||
|
||||
// Pass priority
|
||||
function passPriority() {
|
||||
addLogEntry('Priority passed.');
|
||||
closePriorityWindow();
|
||||
}
|
||||
|
||||
// Open precon browser (stub - opens deck modal for current player)
|
||||
function openPreconBrowser() {
|
||||
if (gameState) openDeckModal(gameState.currentPlayerIndex);
|
||||
}
|
||||
|
||||
// Open collection modal (stub)
|
||||
function openCollectionModal() { showToast('Collection tracker available in full version.'); }
|
||||
|
||||
// Scry dialog stub
|
||||
function showScryDialog(playerId) {
|
||||
if (!gameState) return;
|
||||
const player = gameState.players[playerId];
|
||||
if (!player || player.zones.library.length === 0) {
|
||||
addLogEntry('!! ' + (player ? player.name : 'Player') + ' has no cards to scry.');
|
||||
return;
|
||||
}
|
||||
const count = Math.min(parseInt(prompt('Scry how many? (1-7)', '1') || '1'), 7, player.zones.library.length);
|
||||
if (isNaN(count) || count < 1) return;
|
||||
const topCards = player.zones.library.slice(0, count);
|
||||
const names = topCards.map(c => c.name || '?').join(', ');
|
||||
addLogEntry('<strong>' + player.name + '</strong> scrys ' + count + ': [' + names + ']');
|
||||
}
|
||||
|
||||
// Mill dialog stub
|
||||
function showMillDialog(playerId) {
|
||||
if (!gameState) return;
|
||||
const count = parseInt(prompt('Mill how many cards?', '1') || '1');
|
||||
if (isNaN(count) || count < 1) return;
|
||||
millCards(playerId, count);
|
||||
}
|
||||
|
||||
// Become monarch
|
||||
function becomeMonarch(playerId) {
|
||||
if (!gameState) return;
|
||||
gameState.monarchPlayerIndex = playerId;
|
||||
const player = gameState.players[playerId];
|
||||
addLogEntry('👑 <strong>' + (player ? player.name : 'Player') + '</strong> becomes the Monarch!');
|
||||
updateMonarchBadges();
|
||||
}
|
||||
|
||||
// Empower the life counter right-click on the quadrant labels
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Wire right-click on life values to set-life dialog
|
||||
for (var i = 0; i < 4; i++) {
|
||||
(function(idx) {
|
||||
var lifeEl = document.getElementById('life-' + idx);
|
||||
if (lifeEl) {
|
||||
lifeEl.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
showSetLifeDialog(idx);
|
||||
});
|
||||
}
|
||||
})(i);
|
||||
}
|
||||
|
||||
// Life counter right-click on quadrant buttons too
|
||||
// (already handled via inline onclick in HTML)
|
||||
|
||||
// Auto-detect Commander-AI-Lab backend on page load
|
||||
if (typeof AIBridge !== 'undefined') {
|
||||
AIBridge.init().then(function(status) {
|
||||
var indicator = document.getElementById('backend-status-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = status.connected
|
||||
? (status.modelLoaded ? '\u{1F9E0} ML Backend Connected' : '\u26a1 Backend Connected (no ML model)')
|
||||
: '\u{1F4E1} Local Mode (no backend)';
|
||||
indicator.className = 'backend-status ' +
|
||||
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
|
||||
}
|
||||
|
||||
// Wire up WebSocket push events from backend
|
||||
AIBridge.connector.on('ai:decision', function(msg) {
|
||||
if (!gameState || !msg.action) return;
|
||||
var playerIdx = gameState.currentPlayerIndex;
|
||||
var player = gameState.players[playerIdx];
|
||||
if (player && player.isAI) {
|
||||
AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg);
|
||||
}
|
||||
});
|
||||
|
||||
AIBridge.connector.on('state:snapshot', function(msg) {
|
||||
if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) {
|
||||
console.log('[WS] State snapshot received from backend');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ====================================================
|
||||
DECK BUILDER PANEL (iframe)
|
||||
==================================================== -->
|
||||
<div id="panel-deckbuilder" class="fullscreen-panel hidden">
|
||||
<div class="fullscreen-panel-header">
|
||||
<span>Deck Builder</span>
|
||||
<button class="btn-util" onclick="toggleDeckBuilderPanel()" title="Close">×</button>
|
||||
</div>
|
||||
<iframe src="deckbuilder.html"
|
||||
style="width:100%;height:calc(100vh - 36px);border:none;"
|
||||
id="deckbuilder-iframe">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<div id="panel-collection" class="fullscreen-panel hidden">
|
||||
<div class="fullscreen-panel-header">
|
||||
<span>Collection</span>
|
||||
<button class="btn-util" onclick="toggleCollectionPanel()" title="Close">×</button>
|
||||
</div>
|
||||
<iframe src="collection.html"
|
||||
style="width:100%;height:calc(100vh - 36px);border:none;"
|
||||
id="collection-iframe">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<div id="panel-deckgenerator" class="fullscreen-panel hidden">
|
||||
<div class="fullscreen-panel-header">
|
||||
<span>Auto Deck Generator</span>
|
||||
<button class="btn-util" onclick="toggleDeckGenPanel()" title="Close">×</button>
|
||||
</div>
|
||||
<iframe src="deckgenerator.html"
|
||||
style="width:100%;height:calc(100vh - 36px);border:none;"
|
||||
id="deckgenerator-iframe">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleDeckBuilderPanel() {
|
||||
var panel = document.getElementById('panel-deckbuilder');
|
||||
if (panel) panel.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function toggleDeckGenPanel() {
|
||||
var panel = document.getElementById('panel-deckgenerator');
|
||||
if (panel) panel.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function toggleCollectionPanel() {
|
||||
var panel = document.getElementById('panel-collection');
|
||||
if (panel) panel.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
function toggleLeftPanel() {
|
||||
var panel = document.getElementById('left-action-panel');
|
||||
if (panel) {
|
||||
panel.classList.toggle('collapsed');
|
||||
if (!panel.classList.contains('collapsed') && typeof refreshLeftPanelTriggers === 'function') {
|
||||
refreshLeftPanelTriggers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render the triggers list inside the left action panel.
|
||||
// Mirrors #trigger-stack-body rendering but stays embedded in the panel.
|
||||
function refreshLeftPanelTriggers() {
|
||||
var body = document.getElementById('left-panel-triggers');
|
||||
var countEl = document.getElementById('left-panel-trigger-count');
|
||||
if (!body) return;
|
||||
if (typeof gameState === 'undefined' || !gameState) {
|
||||
if (countEl) countEl.textContent = '0';
|
||||
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
|
||||
return;
|
||||
}
|
||||
var pending = (gameState.triggerStack || []).filter(function(t){ return !t.resolved; });
|
||||
if (countEl) countEl.textContent = String(pending.length);
|
||||
if (pending.length === 0) {
|
||||
body.innerHTML = '<div class="left-panel-empty">No pending triggers.</div>';
|
||||
return;
|
||||
}
|
||||
var icons = (typeof TRIGGER_TYPE_ICONS !== 'undefined') ? TRIGGER_TYPE_ICONS : {};
|
||||
var labels = (typeof TRIGGER_TYPE_LABELS !== 'undefined') ? TRIGGER_TYPE_LABELS : {};
|
||||
var html = '';
|
||||
pending.forEach(function(t) {
|
||||
var icon = icons[t.type] || '\u2747';
|
||||
var label = labels[t.type] || t.type || '';
|
||||
var owner = (gameState.players && gameState.players[t.ownerIndex]) || {};
|
||||
var color = owner.color || '#888';
|
||||
var name = (t.cardName || 'Unknown').replace(/</g,'<');
|
||||
var snip = (t.oracleSnippet || '').replace(/</g,'<');
|
||||
html +=
|
||||
'<div class="lpt-item">' +
|
||||
'<div class="lpt-icon">' + icon + '</div>' +
|
||||
'<div class="lpt-body">' +
|
||||
'<div class="lpt-title">' + name + '</div>' +
|
||||
'<div class="lpt-type">' + label + '</div>' +
|
||||
'<div class="lpt-text">' + snip + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="lpt-dot" style="background:' + color + '"></div>' +
|
||||
'<button class="lpt-dismiss" title="Dismiss" onclick="if(typeof dismissTrigger===\'function\')dismissTrigger(' + t.id + ');refreshLeftPanelTriggers();">✕</button>' +
|
||||
'</div>';
|
||||
});
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
// Populate each seat's Cmd Dmg score-area box with per-opponent badges.
|
||||
// Skips the seat's own player number from the display.
|
||||
function renderCmdDmgInline() {
|
||||
if (typeof gameState === 'undefined' || !gameState || !gameState.players) return;
|
||||
var players = gameState.players;
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var box = document.getElementById('cmd-dmg-inline-' + i);
|
||||
if (!box) continue;
|
||||
var row = box.querySelector('.cmd-dmg-inline-row');
|
||||
if (!row) continue;
|
||||
var me = players[i];
|
||||
if (!me) continue;
|
||||
var html = '';
|
||||
for (var j = 0; j < players.length; j++) {
|
||||
if (j === i) continue; // leave own player number out
|
||||
var op = players[j];
|
||||
if (!op) continue;
|
||||
var dmg = (me.commanderDamage && me.commanderDamage[op.id]) || 0;
|
||||
var danger = dmg >= 21 ? ' danger' : dmg >= 15 ? ' crit' : dmg >= 8 ? ' warn' : '';
|
||||
html +=
|
||||
'<span class="cdi-badge' + danger + '" title="Commander damage from ' + (op.name||'P'+(j+1)) + '">' +
|
||||
'<span class="cdi-dot" style="background:' + (op.color||'#888') + '"></span>' +
|
||||
'P' + (j + 1) + ' <b>' + dmg + '</b>' +
|
||||
'</span>';
|
||||
}
|
||||
row.innerHTML = html || '—';
|
||||
}
|
||||
}
|
||||
|
||||
// Hook into the trigger-stack badge updater so the left panel stays in sync.
|
||||
(function() {
|
||||
if (typeof window === 'undefined') return;
|
||||
var tryHook = function() {
|
||||
if (typeof window.updateTriggerBadge === 'function' && !window.updateTriggerBadge._leftPanelHooked) {
|
||||
var orig = window.updateTriggerBadge;
|
||||
window.updateTriggerBadge = function() {
|
||||
var r = orig.apply(this, arguments);
|
||||
try { refreshLeftPanelTriggers(); } catch(e) {}
|
||||
return r;
|
||||
};
|
||||
window.updateTriggerBadge._leftPanelHooked = true;
|
||||
}
|
||||
// Also wrap adjustCommanderDamage so badges refresh after matrix clicks.
|
||||
if (typeof window.adjustCommanderDamage === 'function' && !window.adjustCommanderDamage._cdmInlineHooked) {
|
||||
var origAdj = window.adjustCommanderDamage;
|
||||
window.adjustCommanderDamage = function() {
|
||||
var r = origAdj.apply(this, arguments);
|
||||
try { renderCmdDmgInline(); } catch(e) {}
|
||||
return r;
|
||||
};
|
||||
window.adjustCommanderDamage._cdmInlineHooked = true;
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', tryHook);
|
||||
} else {
|
||||
tryHook();
|
||||
}
|
||||
// Also attempt the hook again a tick later (engine.js loads after this script in some orderings)
|
||||
setTimeout(tryHook, 0);
|
||||
setTimeout(tryHook, 500);
|
||||
})();
|
||||
|
||||
// Listen for deck exports from the deck builder iframe
|
||||
window.addEventListener('message', function(e) {
|
||||
if (!e.data || e.data.type !== 'DECK_BUILDER_EXPORT') return;
|
||||
|
||||
var deckData = e.data;
|
||||
// Build a decklist text string compatible with importDeckForPlayer()
|
||||
var lines = [];
|
||||
var commanders = deckData.deck.filter(function(c) { return c.is_commander; });
|
||||
var nonCmd = deckData.deck.filter(function(c) { return !c.is_commander; });
|
||||
|
||||
if (commanders.length > 0) {
|
||||
lines.push('[Commander]');
|
||||
commanders.forEach(function(c) { lines.push('1 ' + c.name); });
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('[Deck]');
|
||||
nonCmd.forEach(function(c) { lines.push((c.quantity || 1) + ' ' + c.name); });
|
||||
|
||||
// Store on window for the setup screen to pick up
|
||||
window._pendingDeckImport = {
|
||||
text: lines.join('\n'),
|
||||
deckName: deckData.deckName || 'Imported Deck',
|
||||
commander: deckData.commander || '',
|
||||
};
|
||||
|
||||
// If a game is in progress, import into current player's slot
|
||||
if (typeof gameState !== 'undefined' && gameState && typeof importDeckForPlayer === 'function') {
|
||||
var playerIdx = gameState.currentPlayerIndex || 0;
|
||||
importDeckForPlayer(playerIdx, window._pendingDeckImport.text).then(function() {
|
||||
if (typeof renderHandArea === 'function') renderHandArea();
|
||||
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
|
||||
if (typeof addLogEntry === 'function') {
|
||||
addLogEntry('Imported deck "' + window._pendingDeckImport.deckName + '" from Deck Builder');
|
||||
}
|
||||
});
|
||||
} else if (typeof addLogEntry === 'function') {
|
||||
addLogEntry('Deck "' + window._pendingDeckImport.deckName + '" ready — start a game to use it.');
|
||||
}
|
||||
|
||||
// Close the deck builder panel
|
||||
toggleDeckBuilderPanel();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,275 +0,0 @@
|
||||
// player-panels.js — Enhanced Player Panel Component
|
||||
// Provides collapsible panels with life, commander damage, counters, and emblems.
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Create an enhanced player panel widget for a quadrant.
|
||||
* Replaces the basic life counter with a full tracking panel.
|
||||
*
|
||||
* @param {object} player - Player object from gameState
|
||||
* @param {number} quadrant - 1-4 (matches CSS class p1-p4)
|
||||
* @param {boolean} isActive - Whether this is the active player
|
||||
*/
|
||||
function createEnhancedPlayerPanel(player, quadrant, isActive) {
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'enhanced-player-panel' +
|
||||
(isActive ? ' active-turn' : '') +
|
||||
(player.eliminated ? ' eliminated' : '');
|
||||
panel.id = 'enhanced-panel-' + player.id;
|
||||
panel.setAttribute('data-player-id', player.id);
|
||||
|
||||
// Track collapse state
|
||||
var collapseKey = 'panel-collapsed-' + player.id;
|
||||
var isCollapsed = localStorage.getItem(collapseKey) === 'true';
|
||||
|
||||
// ── Header Row ──────────────────────────────────────────
|
||||
var headerHtml =
|
||||
'<div class="epp-header" onclick="togglePlayerPanel(' + player.id + ')">' +
|
||||
'<span class="epp-collapse-arrow">' + (isCollapsed ? '\u25b8' : '\u25be') + '</span>' +
|
||||
'<div class="epp-name-dot" style="background:' + player.color + '"></div>' +
|
||||
'<span class="epp-name">' + player.name + '</span>' +
|
||||
(player.isAI ? '<span class="epp-ai-badge">AI</span>' : '') +
|
||||
'<span class="epp-life-badge">' +
|
||||
'\u2665 ' + player.life +
|
||||
(player.life < player.startingLife
|
||||
? ' / ' + player.startingLife
|
||||
: '') +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
|
||||
// ── Life Counter Row ────────────────────────────────────
|
||||
var lifeHtml =
|
||||
'<div class="epp-life-row">' +
|
||||
'<button class="epp-life-btn minus" onclick="adjustLife(' + player.id + ',-1)" ' +
|
||||
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',-5)" ' +
|
||||
'title="Left: -1 | Right: -5">\u2212</button>' +
|
||||
'<div class="epp-life-value" id="epp-life-' + player.id + '" ' +
|
||||
'onclick="showSetLifeDialog(' + player.id + ')">' +
|
||||
player.life +
|
||||
'</div>' +
|
||||
'<button class="epp-life-btn plus" onclick="adjustLife(' + player.id + ',1)" ' +
|
||||
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',5)" ' +
|
||||
'title="Left: +1 | Right: +5">+</button>' +
|
||||
'</div>';
|
||||
|
||||
// ── Commander Damage Section ────────────────────────────
|
||||
var otherPlayers = gameState.players.filter(function(p) {
|
||||
return p.id !== player.id && !p.eliminated;
|
||||
});
|
||||
|
||||
var cmdDmgHtml =
|
||||
'<div class="epp-section">' +
|
||||
'<div class="epp-section-label">Commander Damage</div>' +
|
||||
'<div class="epp-cmd-dmg-grid">';
|
||||
|
||||
otherPlayers.forEach(function(op) {
|
||||
var dmg = player.commanderDamage[op.id] || 0;
|
||||
var pct = Math.round((dmg / 21) * 100);
|
||||
var dangerClass = dmg >= 18 ? 'critical' : dmg >= 10 ? 'warning' : '';
|
||||
|
||||
cmdDmgHtml +=
|
||||
'<div class="epp-cmd-dmg-item ' + dangerClass + '">' +
|
||||
'<div class="epp-cmd-dmg-bar" style="width:' + pct + '%"></div>' +
|
||||
'<div class="epp-cmd-dmg-dot" style="background:' + op.color + '"></div>' +
|
||||
'<span class="epp-cmd-dmg-name">' + op.name + '</span>' +
|
||||
'<span class="epp-cmd-dmg-value">' + dmg + '/21</span>' +
|
||||
'<button class="epp-counter-btn" ' +
|
||||
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',1)" ' +
|
||||
'title="Add commander damage">+</button>' +
|
||||
'<button class="epp-counter-btn" ' +
|
||||
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',-1)" ' +
|
||||
'title="Remove commander damage">\u2212</button>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
cmdDmgHtml += '</div></div>';
|
||||
|
||||
// ── Counters Section ────────────────────────────────────
|
||||
var poison = player.poison || 0;
|
||||
var energy = player.energy || 0;
|
||||
var experience = player.experience || 0;
|
||||
var rad = player.rad || 0;
|
||||
|
||||
var countersHtml =
|
||||
'<div class="epp-section">' +
|
||||
'<div class="epp-section-label">Counters</div>' +
|
||||
'<div class="epp-counters-grid">' +
|
||||
_buildCounterWidget('Poison', '\u2620', poison, 10,
|
||||
'adjustPoison(' + player.id + ',1)',
|
||||
'adjustPoison(' + player.id + ',-1)',
|
||||
poison >= 8 ? 'critical' : poison >= 5 ? 'warning' : '') +
|
||||
_buildCounterWidget('Energy', '\u269b', energy, 0,
|
||||
'adjustEnergy(' + player.id + ',1)',
|
||||
'adjustEnergy(' + player.id + ',-1)', '') +
|
||||
_buildCounterWidget('Experience', '\u2605', experience, 0,
|
||||
'adjustExperience(' + player.id + ',1)',
|
||||
'adjustExperience(' + player.id + ',-1)', '') +
|
||||
_buildCounterWidget('Rad', '\u2622', rad, 0,
|
||||
'adjustRad(' + player.id + ',1)',
|
||||
'adjustRad(' + player.id + ',-1)', '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// ── Emblems Section ─────────────────────────────────────
|
||||
var emblems = player.emblems || [];
|
||||
var emblemsHtml =
|
||||
'<div class="epp-section">' +
|
||||
'<div class="epp-section-label">Emblems</div>' +
|
||||
'<div class="epp-emblems">';
|
||||
|
||||
if (emblems.length === 0) {
|
||||
emblemsHtml += '<span class="epp-no-emblems">None</span>';
|
||||
} else {
|
||||
emblems.forEach(function(emb, idx) {
|
||||
emblemsHtml +=
|
||||
'<div class="epp-emblem-chip" title="' + emb.name + '">' +
|
||||
'<span class="epp-emblem-name">' + emb.name + '</span>' +
|
||||
'<button class="epp-emblem-remove" ' +
|
||||
'onclick="event.stopPropagation();removeEmblem(' + player.id + ',' + idx + ')">\u00d7</button>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
|
||||
emblemsHtml +=
|
||||
'<button class="epp-add-emblem-btn" ' +
|
||||
'onclick="event.stopPropagation();showAddEmblemDialog(' + player.id + ')" ' +
|
||||
'title="Add an emblem">+ Emblem</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// ── Assemble Panel ──────────────────────────────────────
|
||||
var bodyHtml =
|
||||
'<div class="epp-body" id="epp-body-' + player.id + '" ' +
|
||||
'style="' + (isCollapsed ? 'display:none;' : '') + '">' +
|
||||
lifeHtml + cmdDmgHtml + countersHtml + emblemsHtml +
|
||||
'</div>';
|
||||
|
||||
panel.innerHTML = headerHtml + bodyHtml;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function _buildCounterWidget(label, icon, value, threshold, onInc, onDec, dangerClass) {
|
||||
return '<div class="epp-counter ' + dangerClass + '">' +
|
||||
'<div class="epp-counter-icon">' + icon + '</div>' +
|
||||
'<div class="epp-counter-info">' +
|
||||
'<span class="epp-counter-value">' + value + '</span>' +
|
||||
'<span class="epp-counter-label">' + label + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="epp-counter-btns">' +
|
||||
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onInc + '">+</button>' +
|
||||
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onDec + '">\u2212</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function togglePlayerPanel(playerId) {
|
||||
var body = document.getElementById('epp-body-' + playerId);
|
||||
var panel = document.getElementById('enhanced-panel-' + playerId);
|
||||
if (!body || !panel) return;
|
||||
|
||||
var isCollapsed = body.style.display === 'none';
|
||||
body.style.display = isCollapsed ? '' : 'none';
|
||||
|
||||
var arrow = panel.querySelector('.epp-collapse-arrow');
|
||||
if (arrow) arrow.textContent = isCollapsed ? '\u25be' : '\u25b8';
|
||||
|
||||
localStorage.setItem('panel-collapsed-' + playerId, !isCollapsed);
|
||||
}
|
||||
|
||||
// Counter adjustment functions
|
||||
function adjustEnergy(playerId, amount) {
|
||||
if (!gameState) return;
|
||||
var player = gameState.players[playerId];
|
||||
if (!player) return;
|
||||
player.energy = Math.max(0, (player.energy || 0) + amount);
|
||||
addLogEntry('<strong>' + player.name + '</strong> energy: ' + player.energy);
|
||||
renderEnhancedPanels();
|
||||
}
|
||||
|
||||
function adjustExperience(playerId, amount) {
|
||||
if (!gameState) return;
|
||||
var player = gameState.players[playerId];
|
||||
if (!player) return;
|
||||
player.experience = Math.max(0, (player.experience || 0) + amount);
|
||||
addLogEntry('<strong>' + player.name + '</strong> experience: ' + player.experience);
|
||||
renderEnhancedPanels();
|
||||
}
|
||||
|
||||
function adjustRad(playerId, amount) {
|
||||
if (!gameState) return;
|
||||
var player = gameState.players[playerId];
|
||||
if (!player) return;
|
||||
player.rad = Math.max(0, (player.rad || 0) + amount);
|
||||
addLogEntry('<strong>' + player.name + '</strong> rad counters: ' + player.rad);
|
||||
renderEnhancedPanels();
|
||||
}
|
||||
|
||||
function showAddEmblemDialog(playerId) {
|
||||
var name = prompt('Enter emblem name (e.g., "Sorin, Vengeful Bloodlord"):');
|
||||
if (!name || !name.trim()) return;
|
||||
if (!gameState) return;
|
||||
var player = gameState.players[playerId];
|
||||
if (!player) return;
|
||||
if (!player.emblems) player.emblems = [];
|
||||
player.emblems.push({ name: name.trim() });
|
||||
addLogEntry('<strong>' + player.name + '</strong> gained emblem: ' + name.trim());
|
||||
renderEnhancedPanels();
|
||||
}
|
||||
|
||||
function removeEmblem(playerId, idx) {
|
||||
if (!gameState) return;
|
||||
var player = gameState.players[playerId];
|
||||
if (!player || !player.emblems) return;
|
||||
var removed = player.emblems.splice(idx, 1);
|
||||
if (removed.length > 0) {
|
||||
addLogEntry('<strong>' + player.name + '</strong> lost emblem: ' + removed[0].name);
|
||||
}
|
||||
renderEnhancedPanels();
|
||||
}
|
||||
|
||||
// index: 0 1 2 3 → area: 1 2 4 3
|
||||
// players[2] → bottom-RIGHT (area-4), players[3] → bottom-LEFT (area-3)
|
||||
// Keeps turn order P1→P2→P3→P4 visually clockwise.
|
||||
var PLAYER_INDEX_TO_QUADRANT = [1, 2, 4, 3];
|
||||
|
||||
/**
|
||||
* Re-render all enhanced player panels in their quadrant positions.
|
||||
*/
|
||||
function renderEnhancedPanels() {
|
||||
if (!gameState) return;
|
||||
|
||||
for (var i = 0; i < gameState.players.length; i++) {
|
||||
var player = gameState.players[i];
|
||||
var quadrant = PLAYER_INDEX_TO_QUADRANT[i] || (i + 1);
|
||||
var areaEl = document.getElementById('player-area-' + quadrant);
|
||||
if (!areaEl) continue;
|
||||
|
||||
// Find or create the enhanced panel container
|
||||
var container = areaEl.querySelector('.epp-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'epp-container';
|
||||
// Insert before the zone row
|
||||
var zoneRow = areaEl.querySelector('.zone-row');
|
||||
if (zoneRow) {
|
||||
areaEl.insertBefore(container, zoneRow);
|
||||
} else {
|
||||
areaEl.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
var isActive = gameState.currentPlayerIndex === i;
|
||||
var panel = createEnhancedPlayerPanel(player, quadrant, isActive);
|
||||
container.innerHTML = '';
|
||||
container.appendChild(panel);
|
||||
|
||||
// Hide the old life counter (we have our own now)
|
||||
var oldLifeCounter = areaEl.querySelector('.life-counter');
|
||||
if (oldLifeCounter) oldLifeCounter.style.display = 'none';
|
||||
|
||||
// Hide old poison badge (we have counters section)
|
||||
var oldPoison = areaEl.querySelector('.poison-badge');
|
||||
if (oldPoison) oldPoison.style.display = 'none';
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
/**
|
||||
* precon-loader.js
|
||||
* Handles loading precon .dck files from the backend into the lan-client simulator.
|
||||
* Depends on: engine.js (gameState, initGame, addLogEntry, renderPlayerPanels)
|
||||
* ai-bridge.js (AIBridge.deckSource.fetchPreconDeck)
|
||||
* ui.js (setupDeckChoices, setupPlayerCount, setupStartingLife)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Scryfall collection endpoint — accepts up to 75 card identifiers per call
|
||||
const SCRYFALL_COLLECTION_URL = 'https://api.scryfall.com/cards/collection';
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Scryfall image cache: cardName -> { imageUrl, typeLine, ... }
|
||||
// Persisted to localStorage so repeat games don't re-hit Scryfall.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
const _SCRYFALL_CACHE_KEY = 'commanderLab.scryfallCache.v1';
|
||||
const _SCRYFALL_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const _SCRYFALL_CACHE_MAX_ENTRIES = 20000;
|
||||
|
||||
let _scryfallCache = {};
|
||||
(function _loadScryfallCacheFromLocalStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_SCRYFALL_CACHE_KEY);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return;
|
||||
const now = Date.now();
|
||||
const stamp = parsed.__stamp || 0;
|
||||
if (now - stamp > _SCRYFALL_CACHE_TTL_MS) {
|
||||
console.log('[scryfall-cache] expired (> 30 days), clearing');
|
||||
localStorage.removeItem(_SCRYFALL_CACHE_KEY);
|
||||
return;
|
||||
}
|
||||
delete parsed.__stamp;
|
||||
_scryfallCache = parsed;
|
||||
const n = Object.keys(_scryfallCache).length;
|
||||
console.log('[scryfall-cache] loaded ' + n + ' entries from localStorage');
|
||||
} catch (e) {
|
||||
console.warn('[scryfall-cache] load failed:', e);
|
||||
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
|
||||
_scryfallCache = {};
|
||||
}
|
||||
})();
|
||||
|
||||
let _scryfallCacheSaveTimer = null;
|
||||
function _persistScryfallCache() {
|
||||
// Debounced — coalesce rapid writes during batch enrichment
|
||||
if (_scryfallCacheSaveTimer) return;
|
||||
_scryfallCacheSaveTimer = setTimeout(function () {
|
||||
_scryfallCacheSaveTimer = null;
|
||||
try {
|
||||
// Trim to MAX_ENTRIES if over (drop entries with no imageUrl first)
|
||||
const keys = Object.keys(_scryfallCache);
|
||||
if (keys.length > _SCRYFALL_CACHE_MAX_ENTRIES) {
|
||||
const trimmed = {};
|
||||
const sorted = keys.sort((a, b) =>
|
||||
(_scryfallCache[b].imageUrl ? 1 : 0) - (_scryfallCache[a].imageUrl ? 1 : 0)
|
||||
).slice(0, _SCRYFALL_CACHE_MAX_ENTRIES);
|
||||
for (const k of sorted) trimmed[k] = _scryfallCache[k];
|
||||
_scryfallCache = trimmed;
|
||||
}
|
||||
const payload = Object.assign({ __stamp: Date.now() }, _scryfallCache);
|
||||
localStorage.setItem(_SCRYFALL_CACHE_KEY, JSON.stringify(payload));
|
||||
} catch (e) {
|
||||
// Quota exceeded — clear and skip. Cache will rebuild next session.
|
||||
console.warn('[scryfall-cache] persist failed (quota?):', e);
|
||||
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
|
||||
}
|
||||
}, 750);
|
||||
}
|
||||
|
||||
function clearScryfallCache() {
|
||||
_scryfallCache = {};
|
||||
try { localStorage.removeItem(_SCRYFALL_CACHE_KEY); } catch (_) {}
|
||||
console.log('[scryfall-cache] cleared');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// startGameWithDecks
|
||||
// Called by ui.js startGame() when players have selected precon decks.
|
||||
// ============================================================
|
||||
async function startGameWithDecks() {
|
||||
const playerNames = [];
|
||||
for (let i = 0; i < (setupPlayerCount || 4); i++) {
|
||||
const inp = document.getElementById('player-name-' + i);
|
||||
playerNames.push(inp && inp.value.trim() ? inp.value.trim() : 'Player ' + (i + 1));
|
||||
}
|
||||
|
||||
initGame(setupPlayerCount || 4, playerNames, setupStartingLife || 40);
|
||||
|
||||
const choices = typeof setupDeckChoices !== 'undefined' ? setupDeckChoices : {};
|
||||
const loadPromises = Object.entries(choices).map(async ([idxStr, choice]) => {
|
||||
const playerIdx = parseInt(idxStr, 10);
|
||||
if (choice.type !== 'precon' || !choice.preconId) return;
|
||||
try {
|
||||
const fileName = choice.preconId.endsWith('.dck') ? choice.preconId : choice.preconId + '.dck';
|
||||
const data = await AIBridge.deckSource.fetchPreconDeck(fileName);
|
||||
if (!data || !data.decklist) {
|
||||
addLogEntry('\u26A0 Could not load deck for Player ' + (playerIdx + 1) + ' (no decklist returned)');
|
||||
return;
|
||||
}
|
||||
loadPreconDeckFromDck(playerIdx, data.decklist, choice.preconId);
|
||||
} catch (e) {
|
||||
console.warn('[precon-loader] Failed to load deck for player', playerIdx, e);
|
||||
addLogEntry('\u26A0 Deck load failed for Player ' + (playerIdx + 1) + ': ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
|
||||
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
|
||||
if (typeof updateActivePlayerHighlight === 'function') updateActivePlayerHighlight();
|
||||
|
||||
// Block on Scryfall enrichment BEFORE the game becomes interactive. This is
|
||||
// critical: the opening 7 cards are dealt via player.zones.hand.push(lib.shift())
|
||||
// directly (bypassing drawCard()), so they don't get _enrichCardFromCache'd at
|
||||
// draw time. Without awaiting here, the AI's first turn fires with empty
|
||||
// typeLine/cmc/oracleText/isRamp/isRemoval flags and can only play lands.
|
||||
addLogEntry('\u23F3 Loading card data from Scryfall...');
|
||||
try {
|
||||
await enrichAllZonesWithScryfall();
|
||||
} catch (e) {
|
||||
console.warn('[precon-loader] Enrichment failed, continuing anyway:', e);
|
||||
}
|
||||
addLogEntry('\u2705 Card data loaded. Game ready.');
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// loadPreconDeckFromDck
|
||||
// Parses a Forge-format .dck file string and populates a player's zones.
|
||||
// ============================================================
|
||||
function loadPreconDeckFromDck(playerIdx, dckContent, deckLabel) {
|
||||
if (!gameState) return;
|
||||
const player = gameState.players[playerIdx];
|
||||
if (!player) return;
|
||||
|
||||
let commander = '';
|
||||
let section = '';
|
||||
const mainCards = [];
|
||||
|
||||
for (const rawLine of dckContent.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('//')) continue;
|
||||
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
section = line.slice(1, -1);
|
||||
continue;
|
||||
}
|
||||
if (line.includes('=') && !line.match(/^\d/)) continue;
|
||||
|
||||
const m = line.match(/^(\d+)\s+(.+?)(?:\|.*)?$/);
|
||||
if (!m) continue;
|
||||
|
||||
const qty = parseInt(m[1], 10);
|
||||
const name = m[2].trim();
|
||||
|
||||
if (section === 'Commander') {
|
||||
commander = name;
|
||||
} else if (section === 'Main') {
|
||||
for (let q = 0; q < qty; q++) mainCards.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
player.zones.command = [];
|
||||
player.zones.library = [];
|
||||
player.zones.hand = [];
|
||||
player.zones.graveyard = [];
|
||||
player.zones.exile = [];
|
||||
|
||||
if (commander) {
|
||||
player.zones.command.push({
|
||||
id: 'cmd-' + playerIdx + '-' + Date.now(),
|
||||
name: commander,
|
||||
imageUrl: '',
|
||||
typeLine: 'Legendary Creature',
|
||||
oracleText: '',
|
||||
manaCost: '',
|
||||
});
|
||||
player.commanderCardName = commander;
|
||||
}
|
||||
|
||||
for (const cardName of mainCards) {
|
||||
player.zones.library.push({
|
||||
id: 'lib-' + playerIdx + '-' + cardName + '-' + Math.random().toString(36).slice(2, 6),
|
||||
name: cardName,
|
||||
imageUrl: '',
|
||||
typeLine: '',
|
||||
oracleText: '',
|
||||
manaCost: '',
|
||||
});
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
const lib = player.zones.library;
|
||||
for (let i = lib.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[lib[i], lib[j]] = [lib[j], lib[i]];
|
||||
}
|
||||
|
||||
// Draw opening hand
|
||||
for (let d = 0; d < 7 && lib.length > 0; d++) {
|
||||
player.zones.hand.push(lib.shift());
|
||||
}
|
||||
|
||||
if (typeof updateZoneCountsForPlayer === 'function') updateZoneCountsForPlayer(playerIdx);
|
||||
if (typeof renderCommanderZone === 'function') renderCommanderZone(playerIdx);
|
||||
if (typeof renderHandZone === 'function') renderHandZone(playerIdx);
|
||||
|
||||
const label = deckLabel ? deckLabel.replace('.dck', '') : 'Unknown Deck';
|
||||
addLogEntry(
|
||||
'\uD83C\uDCCF <strong>' + player.name + '</strong> loaded: <em>' + label + '</em>' +
|
||||
(commander ? ' \u2014 Commander: <strong>' + commander + '</strong>' : '') +
|
||||
' (' + lib.length + ' in library, 7 in hand)'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// SCRYFALL IMAGE ENRICHMENT
|
||||
// After zones are populated with card names, batch-fetch card
|
||||
// data from Scryfall and backfill imageUrl on every card object.
|
||||
// Uses the /cards/collection endpoint (75 cards per request).
|
||||
// Results are cached in _scryfallCache so re-renders are instant.
|
||||
// ============================================================
|
||||
|
||||
async function enrichAllZonesWithScryfall() {
|
||||
if (!gameState) return;
|
||||
|
||||
// Gather all unique card names across all players and all zones
|
||||
const nameSet = new Set();
|
||||
let totalCards = 0;
|
||||
let cacheHits = 0;
|
||||
for (const player of gameState.players) {
|
||||
for (const zone of Object.values(player.zones)) {
|
||||
for (const card of zone) {
|
||||
if (!card.name) continue;
|
||||
totalCards++;
|
||||
if (_scryfallCache[card.name]) {
|
||||
cacheHits++;
|
||||
} else {
|
||||
nameSet.add(card.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nameSet.size === 0) {
|
||||
// Everything was in cache — just apply and bail, no network
|
||||
addLogEntry('\u2705 Card images loaded from cache (' + cacheHits + ' cards).');
|
||||
_applyScryfallDataToAllCards();
|
||||
return;
|
||||
}
|
||||
|
||||
const names = Array.from(nameSet);
|
||||
const hitRatio = totalCards > 0 ? Math.round((cacheHits / totalCards) * 100) : 0;
|
||||
addLogEntry('\uD83C\uDF0D Fetching ' + names.length + ' new cards from Scryfall (' + hitRatio + '% cache hit rate)...');
|
||||
|
||||
// Split into batches of 75 (Scryfall max)
|
||||
const BATCH = 75;
|
||||
for (let i = 0; i < names.length; i += BATCH) {
|
||||
const batch = names.slice(i, i + BATCH);
|
||||
try {
|
||||
await _fetchScryfallBatch(batch);
|
||||
} catch (e) {
|
||||
console.warn('[precon-loader] Scryfall batch failed', e);
|
||||
}
|
||||
// Polite delay between batches to respect Scryfall rate limit (50-100ms)
|
||||
if (i + BATCH < names.length) await _sleep(100);
|
||||
}
|
||||
|
||||
// Apply cached data to every card object in every zone
|
||||
_applyScryfallDataToAllCards();
|
||||
|
||||
addLogEntry('\u2705 Card images loaded.');
|
||||
}
|
||||
|
||||
async function _fetchScryfallBatch(names) {
|
||||
const identifiers = names.map(name => ({ name }));
|
||||
const resp = await fetch(SCRYFALL_COLLECTION_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifiers }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Scryfall HTTP ' + resp.status);
|
||||
const json = await resp.json();
|
||||
|
||||
for (const card of (json.data || [])) {
|
||||
const name = card.name;
|
||||
// Pick the best image: normal > large > small, handle double-faced
|
||||
let imageUrl = '';
|
||||
if (card.image_uris) {
|
||||
imageUrl = card.image_uris.normal || card.image_uris.large || card.image_uris.small || '';
|
||||
} else if (card.card_faces && card.card_faces[0] && card.card_faces[0].image_uris) {
|
||||
imageUrl = card.card_faces[0].image_uris.normal || card.card_faces[0].image_uris.large || '';
|
||||
}
|
||||
_scryfallCache[name] = {
|
||||
imageUrl,
|
||||
typeLine: card.type_line || '',
|
||||
oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '',
|
||||
manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '',
|
||||
cmc: card.cmc || 0,
|
||||
power: card.power || '',
|
||||
toughness: card.toughness || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Mark not-found cards so we don't re-fetch them on next enrichment
|
||||
for (const name of names) {
|
||||
if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' };
|
||||
}
|
||||
|
||||
_persistScryfallCache();
|
||||
}
|
||||
|
||||
function _applyScryfallDataToAllCards() {
|
||||
if (!gameState) return;
|
||||
for (const player of gameState.players) {
|
||||
for (const zone of Object.values(player.zones)) {
|
||||
for (const card of zone) {
|
||||
const data = _scryfallCache[card.name];
|
||||
if (data) {
|
||||
card.imageUrl = data.imageUrl || card.imageUrl;
|
||||
card.typeLine = data.typeLine || card.typeLine;
|
||||
card.oracleText = data.oracleText || card.oracleText;
|
||||
card.manaCost = data.manaCost || card.manaCost;
|
||||
card.cmc = data.cmc || card.cmc || 0;
|
||||
if (data.power) card.power = data.power;
|
||||
if (data.toughness) card.toughness = data.toughness;
|
||||
|
||||
// Tag roles from oracle text so aiCategorizeHand works
|
||||
const ot = (card.oracleText || '').toLowerCase();
|
||||
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
|
||||
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
|
||||
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also enrich battlefield cards owned by this player
|
||||
if (gameState.battlefieldCards) {
|
||||
for (const card of gameState.battlefieldCards) {
|
||||
if (card.ownerIndex !== player.id) continue;
|
||||
const data = _scryfallCache[card.name];
|
||||
if (data) {
|
||||
if (!card.imageUrl) card.imageUrl = data.imageUrl || '';
|
||||
card.typeLine = data.typeLine || card.typeLine;
|
||||
card.oracleText = data.oracleText || card.oracleText;
|
||||
card.manaCost = data.manaCost || card.manaCost;
|
||||
card.cmc = data.cmc || card.cmc || 0;
|
||||
|
||||
const ot = (card.oracleText || '').toLowerCase();
|
||||
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
|
||||
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
|
||||
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger UI refresh so images appear without a full reload
|
||||
if (typeof renderPlayerPanels === 'function') renderPlayerPanels();
|
||||
if (typeof renderCommanderZone === 'function') {
|
||||
for (let i = 0; i < gameState.players.length; i++) renderCommanderZone(i);
|
||||
}
|
||||
if (typeof renderHandZone === 'function') {
|
||||
for (let i = 0; i < gameState.players.length; i++) renderHandZone(i);
|
||||
}
|
||||
if (typeof updateZoneCounts === 'function') updateZoneCounts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously enrich a single card from the Scryfall cache.
|
||||
* Called at draw time so cards have typeLine/cmc/oracle data
|
||||
* before the AI's first turn, even if async enrichment hasn't completed.
|
||||
*/
|
||||
function _enrichCardFromCache(card) {
|
||||
if (!card || !card.name) return;
|
||||
const data = _scryfallCache[card.name];
|
||||
if (!data) return;
|
||||
card.imageUrl = data.imageUrl || card.imageUrl || '';
|
||||
card.typeLine = data.typeLine || card.typeLine || '';
|
||||
card.oracleText = data.oracleText || card.oracleText || '';
|
||||
card.manaCost = data.manaCost || card.manaCost || '';
|
||||
card.cmc = data.cmc || card.cmc || 0;
|
||||
if (data.power) card.power = data.power;
|
||||
if (data.toughness) card.toughness = data.toughness;
|
||||
|
||||
// Tag oracle roles
|
||||
const ot = (card.oracleText || '').toLowerCase();
|
||||
if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot);
|
||||
if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot);
|
||||
if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich a single card object on-demand (useful for cards drawn or played after initial load).
|
||||
* Returns a Promise that resolves when the card's imageUrl is set.
|
||||
*/
|
||||
async function enrichCardWithScryfall(card) {
|
||||
if (!card || !card.name) return;
|
||||
if (card.imageUrl) return; // already has image
|
||||
|
||||
const cached = _scryfallCache[card.name];
|
||||
if (cached) {
|
||||
card.imageUrl = cached.imageUrl || '';
|
||||
card.typeLine = cached.typeLine || card.typeLine;
|
||||
card.oracleText = cached.oracleText || card.oracleText;
|
||||
card.manaCost = cached.manaCost || card.manaCost;
|
||||
return;
|
||||
}
|
||||
|
||||
await _fetchScryfallBatch([card.name]);
|
||||
const data = _scryfallCache[card.name];
|
||||
if (data) {
|
||||
card.imageUrl = data.imageUrl || '';
|
||||
card.typeLine = data.typeLine || card.typeLine;
|
||||
card.oracleText = data.oracleText || card.oracleText;
|
||||
card.manaCost = data.manaCost || card.manaCost;
|
||||
}
|
||||
}
|
||||
|
||||
function _sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Auto-generated from precon-decks/precon-index.json
|
||||
// Run: node scripts/generate-precons-js.js to regenerate
|
||||
var PRECON_DATA = [
|
||||
{"id":"Counterpunch","name":"Counterpunch","commander":"Ghave, Guru of Spores","set":"Commander 2011","fileName":"Counterpunch.dck","colors":[],"year":2011},
|
||||
{"id":"Devour_for_Power_CMD","name":"Devour for Power","commander":"The Mimeoplasm","set":"Commander 2011","fileName":"Devour_for_Power_CMD.dck","colors":[],"year":2011},
|
||||
{"id":"Heavenly_Inferno_CMD","name":"Heavenly Inferno","commander":"Kaalia of the Vast","set":"Commander 2011","fileName":"Heavenly_Inferno_CMD.dck","colors":[],"year":2011},
|
||||
{"id":"Mirror_Mastery","name":"Mirror Mastery","commander":"Riku of Two Reflections","set":"Commander 2011","fileName":"Mirror_Mastery.dck","colors":[],"year":2011},
|
||||
{"id":"Political_Puppets","name":"Political Puppets","commander":"Zedruu the Greathearted","set":"Commander 2011","fileName":"Political_Puppets.dck","colors":[],"year":2011},
|
||||
{"id":"Eternal_Bargain","name":"Eternal Bargain","commander":"Oloro, Ageless Ascetic","set":"Commander 2013","fileName":"Eternal_Bargain.dck","colors":[],"year":2013},
|
||||
{"id":"Evasive_Maneuvers_C13","name":"Evasive Maneuvers","commander":"Derevi, Empyrial Tactician","set":"Commander 2013","fileName":"Evasive_Maneuvers_C13.dck","colors":[],"year":2013},
|
||||
{"id":"Mind_Seize","name":"Mind Seize","commander":"Jeleva, Nephalia's Scourge","set":"Commander 2013","fileName":"Mind_Seize.dck","colors":[],"year":2013},
|
||||
{"id":"Nature_of_the_Beast","name":"Nature of the Beast","commander":"Marath, Will of the Wild","set":"Commander 2013","fileName":"Nature_of_the_Beast.dck","colors":[],"year":2013},
|
||||
{"id":"Power_Hungry","name":"Power Hungry","commander":"Prossh, Skyraider of Kher","set":"Commander 2013","fileName":"Power_Hungry.dck","colors":[],"year":2013},
|
||||
{"id":"Built_From_Scratch_C14","name":"Built From Scratch","commander":"Daretti, Scrap Savant","set":"Commander 2014","fileName":"Built_From_Scratch_C14.dck","colors":[],"year":2014},
|
||||
{"id":"Forged_In_Stone","name":"Forged In Stone","commander":"Nahiri, the Lithomancer","set":"Commander 2014","fileName":"Forged_In_Stone.dck","colors":[],"year":2014},
|
||||
{"id":"Guided_By_Nature","name":"Guided By Nature","commander":"Freyalise, Llanowar's Fury","set":"Commander 2014","fileName":"Guided_By_Nature.dck","colors":[],"year":2014},
|
||||
{"id":"Peer_Through_Time","name":"Peer Through Time","commander":"Teferi, Temporal Archmage","set":"Commander 2014","fileName":"Peer_Through_Time.dck","colors":[],"year":2014},
|
||||
{"id":"Sworn_To_Darkness","name":"Sworn To Darkness","commander":"Ob Nixilis of the Black Oath","set":"Commander 2014","fileName":"Sworn_To_Darkness.dck","colors":[],"year":2014},
|
||||
{"id":"Call_the_Spirits","name":"Call the Spirits","commander":"Daxos the Returned","set":"Commander 2015","fileName":"Call_the_Spirits.dck","colors":[],"year":2015},
|
||||
{"id":"Plunder_the_Graves_C15","name":"Plunder the Graves","commander":"Meren of Clan Nel Toth","set":"Commander 2015","fileName":"Plunder_the_Graves_C15.dck","colors":[],"year":2015},
|
||||
{"id":"Seize_Control","name":"Seize Control","commander":"Mizzix of the Izmagnus","set":"Commander 2015","fileName":"Seize_Control.dck","colors":[],"year":2015},
|
||||
{"id":"Swell_the_Host","name":"Swell the Host","commander":"Ezuri, Claw of Progress","set":"Commander 2015","fileName":"Swell_the_Host.dck","colors":[],"year":2015},
|
||||
{"id":"Wade_into_Battle_C15","name":"Wade into Battle","commander":"Kalemne, Disciple of Iroas","set":"Commander 2015","fileName":"Wade_into_Battle_C15.dck","colors":[],"year":2015},
|
||||
{"id":"Breed_Lethality_C16","name":"Breed Lethality","commander":"Atraxa, Praetors' Voice","set":"Commander 2016","fileName":"Breed_Lethality_C16.dck","colors":[],"year":2016},
|
||||
{"id":"Entropic_Uprising","name":"Entropic Uprising","commander":"Yidris, Maelstrom Wielder","set":"Commander 2016","fileName":"Entropic_Uprising.dck","colors":[],"year":2016},
|
||||
{"id":"Invent_Superiority","name":"Invent Superiority","commander":"Breya, Etherium Shaper","set":"Commander 2016","fileName":"Invent_Superiority.dck","colors":[],"year":2016},
|
||||
{"id":"Open_Hostility","name":"Open Hostility","commander":"Saskia the Unyielding","set":"Commander 2016","fileName":"Open_Hostility.dck","colors":[],"year":2016},
|
||||
{"id":"Stalwart_Unity","name":"Stalwart Unity","commander":"Kynaios and Tiro of Meletis","set":"Commander 2016","fileName":"Stalwart_Unity.dck","colors":[],"year":2016},
|
||||
{"id":"Evasive_Maneuvers_CMA","name":"Evasive Maneuvers","commander":"Derevi, Empyrial Tactician","set":"Commander Anthology","fileName":"Evasive_Maneuvers_CMA.dck","colors":[],"year":2017},
|
||||
{"id":"Guided_by_Nature","name":"Guided by Nature","commander":"Freyalise, Llanowar's Fury","set":"Commander Anthology","fileName":"Guided_by_Nature.dck","colors":[],"year":2017},
|
||||
{"id":"Heavenly_Inferno_CMA","name":"Heavenly Inferno","commander":"Kaalia of the Vast","set":"Commander Anthology","fileName":"Heavenly_Inferno_CMA.dck","colors":[],"year":2017},
|
||||
{"id":"Plunder_the_Graves_CMA","name":"Plunder the Graves","commander":"Meren of Clan Nel Toth","set":"Commander Anthology","fileName":"Plunder_the_Graves_CMA.dck","colors":[],"year":2017},
|
||||
{"id":"Arcane_Wizardry","name":"Arcane Wizardry","commander":"Inalla, Archmage Ritualist","set":"Commander 2017","fileName":"Arcane_Wizardry.dck","colors":[],"year":2017},
|
||||
{"id":"Draconic_Domination","name":"Draconic Domination","commander":"The Ur-Dragon","set":"Commander 2017","fileName":"Draconic_Domination.dck","colors":[],"year":2017},
|
||||
{"id":"Feline_Ferocity","name":"Feline Ferocity","commander":"Arahbo, Roar of the World","set":"Commander 2017","fileName":"Feline_Ferocity.dck","colors":[],"year":2017},
|
||||
{"id":"Vampiric_Bloodlust","name":"Vampiric Bloodlust","commander":"Edgar Markov","set":"Commander 2017","fileName":"Vampiric_Bloodlust.dck","colors":[],"year":2017},
|
||||
{"id":"Breed_Lethality_CM2","name":"Breed Lethality","commander":"Atraxa, Praetors' Voice","set":"Commander Anthology Volume II","fileName":"Breed_Lethality_CM2.dck","colors":[],"year":2018},
|
||||
{"id":"Built_From_Scratch_CM2","name":"Built From Scratch","commander":"Daretti, Scrap Savant","set":"Commander Anthology Volume II","fileName":"Built_From_Scratch_CM2.dck","colors":[],"year":2018},
|
||||
{"id":"Devour_for_Power_CM2","name":"Devour for Power","commander":"The Mimeoplasm","set":"Commander Anthology Volume II","fileName":"Devour_for_Power_CM2.dck","colors":[],"year":2018},
|
||||
{"id":"Wade_into_Battle_CM2","name":"Wade into Battle","commander":"Kalemne, Disciple of Iroas","set":"Commander Anthology Volume II","fileName":"Wade_into_Battle_CM2.dck","colors":[],"year":2018},
|
||||
{"id":"Adaptive_Enchantment","name":"Adaptive Enchantment","commander":"Estrid, the Masked","set":"Commander 2018","fileName":"Adaptive_Enchantment.dck","colors":[],"year":2018},
|
||||
{"id":"Exquisite_Invention","name":"Exquisite Invention","commander":"Saheeli, the Gifted","set":"Commander 2018","fileName":"Exquisite_Invention.dck","colors":[],"year":2018},
|
||||
{"id":"Natures_Vengeance","name":"Nature's Vengeance","commander":"Lord Windgrace","set":"Commander 2018","fileName":"Natures_Vengeance.dck","colors":[],"year":2018},
|
||||
{"id":"Subjective_Reality","name":"Subjective Reality","commander":"Aminatou, the Fateshifter","set":"Commander 2018","fileName":"Subjective_Reality.dck","colors":[],"year":2018},
|
||||
{"id":"Faceless_Menace","name":"Faceless Menace","commander":"Kadena, Slinking Sorcerer","set":"Commander 2019","fileName":"Faceless_Menace.dck","colors":[],"year":2019},
|
||||
{"id":"Merciless_Rage","name":"Merciless Rage","commander":"Anje Falkenrath","set":"Commander 2019","fileName":"Merciless_Rage.dck","colors":[],"year":2019},
|
||||
{"id":"Mystic_Intellect","name":"Mystic Intellect","commander":"Sevinne, the Chronoclasm","set":"Commander 2019","fileName":"Mystic_Intellect.dck","colors":[],"year":2019},
|
||||
{"id":"Primal_Genesis","name":"Primal Genesis","commander":"Ghired, Conclave Exile","set":"Commander 2019","fileName":"Primal_Genesis.dck","colors":[],"year":2019},
|
||||
{"id":"Arcane_Maelstrom","name":"Arcane Maelstrom","commander":"Kalamax, the Stormsire","set":"Commander 2020","fileName":"Arcane_Maelstrom.dck","colors":[],"year":2020},
|
||||
{"id":"Enhanced_Evolution","name":"Enhanced Evolution","commander":"Otrimi, the Ever-Playful","set":"Commander 2020","fileName":"Enhanced_Evolution.dck","colors":[],"year":2020},
|
||||
{"id":"Ruthless_Regiment","name":"Ruthless Regiment","commander":"Jirina Kudro","set":"Commander 2020","fileName":"Ruthless_Regiment.dck","colors":[],"year":2020},
|
||||
{"id":"Symbiotic_Swarm","name":"Symbiotic Swarm","commander":"Kathril, Aspect Warper","set":"Commander 2020","fileName":"Symbiotic_Swarm.dck","colors":[],"year":2020},
|
||||
{"id":"Timeless_Wisdom","name":"Timeless Wisdom","commander":"Gavi, Nest Warden","set":"Commander 2020","fileName":"Timeless_Wisdom.dck","colors":[],"year":2020},
|
||||
{"id":"Lands_Wrath","name":"Land's Wrath","commander":"Obuun, Mul Daya Ancestor","set":"Zendikar Rising Commander","fileName":"Lands_Wrath.dck","colors":[],"year":2020},
|
||||
{"id":"Sneak_Attack","name":"Sneak Attack","commander":"Anowon, the Ruin Thief","set":"Zendikar Rising Commander","fileName":"Sneak_Attack.dck","colors":[],"year":2020},
|
||||
{"id":"Arm_for_Battle","name":"Arm for Battle","commander":"Wyleth, Soul of Steel","set":"Commander Legends","fileName":"Arm_for_Battle.dck","colors":[],"year":2020},
|
||||
{"id":"Reap_the_Tides","name":"Reap the Tides","commander":"Aesi, Tyrant of Gyre Strait","set":"Commander Legends","fileName":"Reap_the_Tides.dck","colors":[],"year":2020},
|
||||
{"id":"Elven_Empire","name":"Elven Empire","commander":"Lathril, Blade of the Elves","set":"Kaldheim Commander","fileName":"Elven_Empire.dck","colors":[],"year":2021},
|
||||
{"id":"Phantom_Premonition","name":"Phantom Premonition","commander":"Ranar the Ever-Watchful","set":"Kaldheim Commander","fileName":"Phantom_Premonition.dck","colors":[],"year":2021},
|
||||
{"id":"Lorehold_Legacies","name":"Lorehold Legacies","commander":"Osgir, the Reconstructor","set":"Commander 2021","fileName":"Lorehold_Legacies.dck","colors":[],"year":2021},
|
||||
{"id":"Prismari_Performance","name":"Prismari Performance","commander":"Zaffai, Thunder Conductor","set":"Commander 2021","fileName":"Prismari_Performance.dck","colors":[],"year":2021},
|
||||
{"id":"Quantum_Quandrix","name":"Quantum Quandrix","commander":"Adrix and Nev, Twincasters","set":"Commander 2021","fileName":"Quantum_Quandrix.dck","colors":[],"year":2021},
|
||||
{"id":"Silverquill_Statement","name":"Silverquill Statement","commander":"Breena, the Demagogue","set":"Commander 2021","fileName":"Silverquill_Statement.dck","colors":[],"year":2021},
|
||||
{"id":"Witherbloom_Witchcraft","name":"Witherbloom Witchcraft","commander":"Willowdusk, Essence Seer","set":"Commander 2021","fileName":"Witherbloom_Witchcraft.dck","colors":[],"year":2021},
|
||||
{"id":"Aura_of_Courage","name":"Aura of Courage","commander":"Galea, Kindler of Hope","set":"Forgotten Realms Commander","fileName":"Aura_of_Courage.dck","colors":[],"year":2021},
|
||||
{"id":"Draconic_Rage","name":"Draconic Rage","commander":"Vrondiss, Rage of Ancients","set":"Forgotten Realms Commander","fileName":"Draconic_Rage.dck","colors":[],"year":2021},
|
||||
{"id":"Dungeons_of_Death","name":"Dungeons of Death","commander":"Sefris of the Hidden Ways","set":"Forgotten Realms Commander","fileName":"Dungeons_of_Death.dck","colors":[],"year":2021},
|
||||
{"id":"Planar_Portal","name":"Planar Portal","commander":"Prosper, Tome-Bound","set":"Forgotten Realms Commander","fileName":"Planar_Portal.dck","colors":[],"year":2021},
|
||||
{"id":"Coven_Counters","name":"Coven Counters","commander":"Leinore, Autumn Sovereign","set":"Midnight Hunt Commander","fileName":"Coven_Counters.dck","colors":[],"year":2021},
|
||||
{"id":"Undead_Unleashed","name":"Undead Unleashed","commander":"Wilhelt, the Rotcleaver","set":"Midnight Hunt Commander","fileName":"Undead_Unleashed.dck","colors":[],"year":2021},
|
||||
{"id":"Spirit_Squadron","name":"Spirit Squadron","commander":"Millicent, Restless Revenant","set":"Crimson Vow Commander","fileName":"Spirit_Squadron.dck","colors":[],"year":2021},
|
||||
{"id":"Vampiric_Bloodline","name":"Vampiric Bloodline","commander":"Strefan, Maurer Progenitor","set":"Crimson Vow Commander","fileName":"Vampiric_Bloodline.dck","colors":[],"year":2021},
|
||||
{"id":"Buckle_Up","name":"Buckle Up","commander":"Kotori, Pilot Prodigy","set":"Neon Dynasty Commander","fileName":"Buckle_Up.dck","colors":[],"year":2022},
|
||||
{"id":"Upgrades_Unleashed","name":"Upgrades Unleashed","commander":"Chishiro, the Shattered Blade","set":"Neon Dynasty Commander","fileName":"Upgrades_Unleashed.dck","colors":[],"year":2022},
|
||||
{"id":"Heads_I_Win,_Tails_You_Lose","name":"Heads I Win, Tails You Lose","commander":"Zndrsplt, Eye of Wisdom","set":"Secret Lair Drop","fileName":"Heads_I_Win,_Tails_You_Lose.dck","colors":[],"year":2022},
|
||||
{"id":"Bedecked_Brokers","name":"Bedecked Brokers","commander":"Perrie, the Pulverizer","set":"New Capenna Commander","fileName":"Bedecked_Brokers.dck","colors":[],"year":2022},
|
||||
{"id":"Cabaretti_Cacophony","name":"Cabaretti Cacophony","commander":"Kitt Kanto, Mayhem Diva","set":"New Capenna Commander","fileName":"Cabaretti_Cacophony.dck","colors":[],"year":2022},
|
||||
{"id":"Maestros_Massacre","name":"Maestros Massacre","commander":"Anhelo, the Painter","set":"New Capenna Commander","fileName":"Maestros_Massacre.dck","colors":[],"year":2022},
|
||||
{"id":"Obscura_Operation","name":"Obscura Operation","commander":"Kamiz, Obscura Oculus","set":"New Capenna Commander","fileName":"Obscura_Operation.dck","colors":[],"year":2022},
|
||||
{"id":"Riveteers_Rampage","name":"Riveteers Rampage","commander":"Henzie \"Toolbox\" Torre","set":"New Capenna Commander","fileName":"Riveteers_Rampage.dck","colors":[],"year":2022},
|
||||
{"id":"Draconic_Dissent","name":"Draconic Dissent","commander":"Firkraag, Cunning Instigator","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Draconic_Dissent.dck","colors":[],"year":2022},
|
||||
{"id":"Exit_from_Exile","name":"Exit from Exile","commander":"Faldorn, Dread Wolf Herald","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Exit_from_Exile.dck","colors":[],"year":2022},
|
||||
{"id":"Mind_Flayarrrs","name":"Mind Flayarrrs","commander":"Captain N'ghathrod","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Mind_Flayarrrs.dck","colors":[],"year":2022},
|
||||
{"id":"Party_Time","name":"Party Time","commander":"Nalia de'Arnise","set":"Commander Legends: Battle for Baldur's Gate","fileName":"Party_Time.dck","colors":[],"year":2022},
|
||||
{"id":"Legends_Legacy","name":"Legends' Legacy","commander":"Dihada, Binder of Wills","set":"Dominaria United Commander","fileName":"Legends_Legacy.dck","colors":[],"year":2022},
|
||||
{"id":"Painbow","name":"Painbow","commander":"Jared Carthalion","set":"Dominaria United Commander","fileName":"Painbow.dck","colors":[],"year":2022},
|
||||
{"id":"Forces_of_the_Imperium","name":"Forces of the Imperium","commander":"Inquisitor Greyfax","set":"Warhammer 40,000 Commander","fileName":"Forces_of_the_Imperium.dck","colors":[],"year":2022},
|
||||
{"id":"Forces_of_the_Imperium_Collectors_Edition","name":"Forces of the Imperium Collector's Edition","commander":"Inquisitor Greyfax","set":"Warhammer 40,000 Commander","fileName":"Forces_of_the_Imperium_Collectors_Edition.dck","colors":[],"year":2022},
|
||||
{"id":"Necron_Dynasties","name":"Necron Dynasties","commander":"Szarekh, the Silent King","set":"Warhammer 40,000 Commander","fileName":"Necron_Dynasties.dck","colors":[],"year":2022},
|
||||
{"id":"Necron_Dynasties_Collectors_Edition","name":"Necron Dynasties Collector's Edition","commander":"Szarekh, the Silent King","set":"Warhammer 40,000 Commander","fileName":"Necron_Dynasties_Collectors_Edition.dck","colors":[],"year":2022},
|
||||
{"id":"The_Ruinous_Powers","name":"The Ruinous Powers","commander":"Abaddon the Despoiler","set":"Warhammer 40,000 Commander","fileName":"The_Ruinous_Powers.dck","colors":[],"year":2022},
|
||||
{"id":"The_Ruinous_Powers_Collectors_Edition","name":"The Ruinous Powers Collector's Edition","commander":"Abaddon the Despoiler","set":"Warhammer 40,000 Commander","fileName":"The_Ruinous_Powers_Collectors_Edition.dck","colors":[],"year":2022},
|
||||
{"id":"Tyranid_Swarm","name":"Tyranid Swarm","commander":"The Swarmlord","set":"Warhammer 40,000 Commander","fileName":"Tyranid_Swarm.dck","colors":[],"year":2022},
|
||||
{"id":"Tyranid_Swarm_Collectors_Edition","name":"Tyranid Swarm Collector's Edition","commander":"The Swarmlord","set":"Warhammer 40,000 Commander","fileName":"Tyranid_Swarm_Collectors_Edition.dck","colors":[],"year":2022},
|
||||
{"id":"Mishras_Burnished_Banner","name":"Mishra's Burnished Banner","commander":"Mishra, Eminent One","set":"The Brothers' War Commander","fileName":"Mishras_Burnished_Banner.dck","colors":[],"year":2022},
|
||||
{"id":"Urzas_Iron_Alliance","name":"Urza's Iron Alliance","commander":"Urza, Chief Artificer","set":"The Brothers' War Commander","fileName":"Urzas_Iron_Alliance.dck","colors":[],"year":2022},
|
||||
{"id":"Chaos_Incarnate","name":"Chaos Incarnate","commander":"Kardur, Doomscourge","set":"Starter Commander Decks","fileName":"Chaos_Incarnate.dck","colors":[],"year":2022},
|
||||
{"id":"Draconic_Destruction","name":"Draconic Destruction","commander":"Atarka, World Render","set":"Starter Commander Decks","fileName":"Draconic_Destruction.dck","colors":[],"year":2022},
|
||||
{"id":"First_Flight","name":"First Flight","commander":"Isperia, Supreme Judge","set":"Starter Commander Decks","fileName":"First_Flight.dck","colors":[],"year":2022},
|
||||
{"id":"Grave_Danger","name":"Grave Danger","commander":"Gisa and Geralf","set":"Starter Commander Decks","fileName":"Grave_Danger.dck","colors":[],"year":2022},
|
||||
{"id":"Token_Triumph","name":"Token Triumph","commander":"Emmara, Soul of the Accord","set":"Starter Commander Decks","fileName":"Token_Triumph.dck","colors":[],"year":2022},
|
||||
{"id":"Corrupting_Influence","name":"Corrupting Influence","commander":"Ixhel, Scion of Atraxa","set":"Phyrexia: All Will Be One Commander","fileName":"Corrupting_Influence.dck","colors":[],"year":2023},
|
||||
{"id":"Rebellion_Rising","name":"Rebellion Rising","commander":"Neyali, Suns' Vanguard","set":"Phyrexia: All Will Be One Commander","fileName":"Rebellion_Rising.dck","colors":[],"year":2023},
|
||||
{"id":"From_Cute_to_Brute","name":"From Cute to Brute","commander":"Esika, God of the Tree","set":"Secret Lair Drop","fileName":"From_Cute_to_Brute.dck","colors":[],"year":2023},
|
||||
{"id":"Elven_Council","name":"Elven Council","commander":"Galadriel, Elven-Queen","set":"Tales of Middle-earth Commander","fileName":"Elven_Council.dck","colors":[],"year":2023},
|
||||
{"id":"Food_and_Fellowship","name":"Food and Fellowship","commander":"Frodo, Adventurous Hobbit","set":"Tales of Middle-earth Commander","fileName":"Food_and_Fellowship.dck","colors":[],"year":2023},
|
||||
{"id":"Riders_of_Rohan","name":"Riders of Rohan","commander":"Éowyn, Shieldmaiden","set":"Tales of Middle-earth Commander","fileName":"Riders_of_Rohan.dck","colors":[],"year":2023},
|
||||
{"id":"The_Hosts_of_Mordor","name":"The Hosts of Mordor","commander":"Sauron, Lord of the Rings","set":"Tales of Middle-earth Commander","fileName":"The_Hosts_of_Mordor.dck","colors":[],"year":2023},
|
||||
{"id":"Eldrazi_Unbound","name":"Eldrazi Unbound","commander":"Zhulodok, Void Gorger","set":"Commander Masters","fileName":"Eldrazi_Unbound.dck","colors":[],"year":2023},
|
||||
{"id":"Enduring_Enchantments","name":"Enduring Enchantments","commander":"Anikthea, Hand of Erebos","set":"Commander Masters","fileName":"Enduring_Enchantments.dck","colors":[],"year":2023},
|
||||
{"id":"Planeswalker_Party","name":"Planeswalker Party","commander":"Commodore Guff","set":"Commander Masters","fileName":"Planeswalker_Party.dck","colors":[],"year":2023},
|
||||
{"id":"Sliver_Swarm","name":"Sliver Swarm","commander":"Sliver Gravemother","set":"Commander Masters","fileName":"Sliver_Swarm.dck","colors":[],"year":2023},
|
||||
{"id":"Angels_Theyre_Just_Like_Us_but_Cooler_and_with_Wings","name":"Angels: They're Just Like Us but Cooler and with Wings","commander":"Gisela, the Broken Blade","set":"Secret Lair Drop","fileName":"Angels_Theyre_Just_Like_Us_but_Cooler_and_with_Wings.dck","colors":[],"year":2023},
|
||||
{"id":"Fae_Dominion","name":"Fae Dominion","commander":"Tegwyll, Duke of Splendor","set":"Wilds of Eldraine Commander","fileName":"Fae_Dominion.dck","colors":[],"year":2023},
|
||||
{"id":"Virtue_and_Valor","name":"Virtue and Valor","commander":"Ellivere of the Wild Court","set":"Wilds of Eldraine Commander","fileName":"Virtue_and_Valor.dck","colors":[],"year":2023},
|
||||
{"id":"Ahoy_Mateys","name":"Ahoy Mateys","commander":"Admiral Brass, Unsinkable","set":"The Lost Caverns of Ixalan Commander","fileName":"Ahoy_Mateys.dck","colors":[],"year":2023},
|
||||
{"id":"Blood_Rites","name":"Blood Rites","commander":"Clavileño, First of the Blessed","set":"The Lost Caverns of Ixalan Commander","fileName":"Blood_Rites.dck","colors":[],"year":2023},
|
||||
{"id":"Explorers_of_the_Deep","name":"Explorers of the Deep","commander":"Hakbal of the Surging Soul","set":"The Lost Caverns of Ixalan Commander","fileName":"Explorers_of_the_Deep.dck","colors":[],"year":2023},
|
||||
{"id":"Veloci-Ramp-Tor","name":"Veloci-Ramp-Tor","commander":"Pantlaza, Sun-Favored","set":"The Lost Caverns of Ixalan Commander","fileName":"Veloci-Ramp-Tor.dck","colors":[],"year":2023},
|
||||
{"id":"Raining_Cats_and_Dogs","name":"Raining Cats and Dogs","commander":"Rin and Seri, Inseparable","set":"Secret Lair Drop","fileName":"Raining_Cats_and_Dogs.dck","colors":[],"year":2024},
|
||||
{"id":"Blame_Game","name":"Blame Game","commander":"Nelly Borca, Impulsive Accuser","set":"Murders at Karlov Manor Commander","fileName":"Blame_Game.dck","colors":[],"year":2024},
|
||||
{"id":"Deadly_Disguise","name":"Deadly Disguise","commander":"Kaust, Eyes of the Glade","set":"Murders at Karlov Manor Commander","fileName":"Deadly_Disguise.dck","colors":[],"year":2024},
|
||||
{"id":"Deep_Clue_Sea","name":"Deep Clue Sea","commander":"Morska, Undersea Sleuth","set":"Murders at Karlov Manor Commander","fileName":"Deep_Clue_Sea.dck","colors":[],"year":2024},
|
||||
{"id":"Revenant_Recon","name":"Revenant Recon","commander":"Mirko, Obsessive Theorist","set":"Murders at Karlov Manor Commander","fileName":"Revenant_Recon.dck","colors":[],"year":2024},
|
||||
{"id":"Hail,_Caesar","name":"Hail, Caesar","commander":"Caesar, Legion's Emperor","set":"Fallout","fileName":"Hail,_Caesar.dck","colors":[],"year":2024},
|
||||
{"id":"Mutant_Menace","name":"Mutant Menace","commander":"The Wise Mothman","set":"Fallout","fileName":"Mutant_Menace.dck","colors":[],"year":2024},
|
||||
{"id":"Science","name":"Science!","commander":"Dr. Madison Li","set":"Fallout","fileName":"Science.dck","colors":[],"year":2024},
|
||||
{"id":"Scrappy_Survivors","name":"Scrappy Survivors","commander":"Dogmeat, Ever Loyal","set":"Fallout","fileName":"Scrappy_Survivors.dck","colors":[],"year":2024},
|
||||
{"id":"Desert_Bloom","name":"Desert Bloom","commander":"Yuma, Proud Protector","set":"Outlaws of Thunder Junction Commander","fileName":"Desert_Bloom.dck","colors":[],"year":2024},
|
||||
{"id":"Grand_Larceny","name":"Grand Larceny","commander":"Gonti, Canny Acquisitor","set":"Outlaws of Thunder Junction Commander","fileName":"Grand_Larceny.dck","colors":[],"year":2024},
|
||||
{"id":"Most_Wanted","name":"Most Wanted","commander":"Olivia, Opulent Outlaw","set":"Outlaws of Thunder Junction Commander","fileName":"Most_Wanted.dck","colors":[],"year":2024},
|
||||
{"id":"Quick_Draw","name":"Quick Draw","commander":"Stella Lee, Wild Card","set":"Outlaws of Thunder Junction Commander","fileName":"Quick_Draw.dck","colors":[],"year":2024},
|
||||
{"id":"Creative_Energy","name":"Creative Energy","commander":"Satya, Aetherflux Genius","set":"Modern Horizons 3 Commander","fileName":"Creative_Energy.dck","colors":[],"year":2024},
|
||||
{"id":"Creative_Energy_Collectors_Edition","name":"Creative Energy Collector's Edition","commander":"Satya, Aetherflux Genius","set":"Modern Horizons 3 Commander","fileName":"Creative_Energy_Collectors_Edition.dck","colors":[],"year":2024},
|
||||
{"id":"Eldrazi_Incursion","name":"Eldrazi Incursion","commander":"Ulalek, Fused Atrocity","set":"Modern Horizons 3 Commander","fileName":"Eldrazi_Incursion.dck","colors":[],"year":2024},
|
||||
{"id":"Eldrazi_Incursion_Collectors_Edition","name":"Eldrazi Incursion Collector's Edition","commander":"Ulalek, Fused Atrocity","set":"Modern Horizons 3 Commander","fileName":"Eldrazi_Incursion_Collectors_Edition.dck","colors":[],"year":2024},
|
||||
{"id":"Graveyard_Overdrive","name":"Graveyard Overdrive","commander":"Disa the Restless","set":"Modern Horizons 3 Commander","fileName":"Graveyard_Overdrive.dck","colors":[],"year":2024},
|
||||
{"id":"Graveyard_Overdrive_Collectors_Edition","name":"Graveyard Overdrive Collector's Edition","commander":"Disa the Restless","set":"Modern Horizons 3 Commander","fileName":"Graveyard_Overdrive_Collectors_Edition.dck","colors":[],"year":2024},
|
||||
{"id":"Tricky_Terrain","name":"Tricky Terrain","commander":"Omo, Queen of Vesuva","set":"Modern Horizons 3 Commander","fileName":"Tricky_Terrain.dck","colors":[],"year":2024},
|
||||
{"id":"Tricky_Terrain_Collectors_Edition","name":"Tricky Terrain Collector's Edition","commander":"Omo, Queen of Vesuva","set":"Modern Horizons 3 Commander","fileName":"Tricky_Terrain_Collectors_Edition.dck","colors":[],"year":2024},
|
||||
{"id":"Animated_Army","name":"Animated Army","commander":"Bello, Bard of the Brambles","set":"Bloomburrow Commander","fileName":"Animated_Army.dck","colors":[],"year":2024},
|
||||
{"id":"Family_Matters","name":"Family Matters","commander":"Zinnia, Valley's Voice","set":"Bloomburrow Commander","fileName":"Family_Matters.dck","colors":[],"year":2024},
|
||||
{"id":"Peace_Offering","name":"Peace Offering","commander":"Ms. Bumbleflower","set":"Bloomburrow Commander","fileName":"Peace_Offering.dck","colors":[],"year":2024},
|
||||
{"id":"Squirreled_Away","name":"Squirreled Away","commander":"Hazel of the Rootbloom","set":"Bloomburrow Commander","fileName":"Squirreled_Away.dck","colors":[],"year":2024},
|
||||
{"id":"20_Ways_to_Win","name":"20 Ways to Win","commander":"Go-Shintai of Life's Origin","set":"Secret Lair Drop","fileName":"20_Ways_to_Win.dck","colors":[],"year":2024},
|
||||
{"id":"Eternal_Might","name":"Eternal Might","commander":"Temmet, Naktamun's Will","set":"Aetherdrift Commander","fileName":"Eternal_Might.dck","colors":[],"year":2025},
|
||||
{"id":"Living_Energy","name":"Living Energy","commander":"Saheeli, Radiant Creator","set":"Aetherdrift Commander","fileName":"Living_Energy.dck","colors":[],"year":2025},
|
||||
{"id":"Abzan_Armor","name":"Abzan Armor","commander":"Felothar the Steadfast","set":"Tarkir: Dragonstorm Commander","fileName":"Abzan_Armor.dck","colors":[],"year":2025},
|
||||
{"id":"Jeskai_Striker","name":"Jeskai Striker","commander":"Shiko and Narset, Unified","set":"Tarkir: Dragonstorm Commander","fileName":"Jeskai_Striker.dck","colors":[],"year":2025},
|
||||
{"id":"Mardu_Surge","name":"Mardu Surge","commander":"Zurgo Stormrender","set":"Tarkir: Dragonstorm Commander","fileName":"Mardu_Surge.dck","colors":[],"year":2025},
|
||||
{"id":"Sultai_Arisen","name":"Sultai Arisen","commander":"Teval, the Balanced Scale","set":"Tarkir: Dragonstorm Commander","fileName":"Sultai_Arisen.dck","colors":[],"year":2025},
|
||||
{"id":"Temur_Roar","name":"Temur Roar","commander":"Eshki, Temur's Roar","set":"Tarkir: Dragonstorm Commander","fileName":"Temur_Roar.dck","colors":[],"year":2025},
|
||||
{"id":"Everyones_Invited","name":"Everyone's Invited!","commander":"Morophon, the Boundless","set":"Secret Lair Drop","fileName":"Everyones_Invited.dck","colors":[],"year":2025},
|
||||
{"id":"Counter_Blitz_(FINAL_FANTASY_X)","name":"Counter Blitz (FINAL FANTASY X)","commander":"Tidus, Yuna's Guardian","set":"Final Fantasy Commander","fileName":"Counter_Blitz_(FINAL_FANTASY_X).dck","colors":[],"year":2025},
|
||||
{"id":"Counter_Blitz_Collectors_Edition_(FINAL_FANTASY_X)","name":"Counter Blitz Collector's Edition (FINAL FANTASY X)","commander":"Tidus, Yuna's Guardian","set":"Final Fantasy Commander","fileName":"Counter_Blitz_Collectors_Edition_(FINAL_FANTASY_X).dck","colors":[],"year":2025},
|
||||
{"id":"Limit_Break_(FINAL_FANTASY_VII)","name":"Limit Break (FINAL FANTASY VII)","commander":"Cloud, Ex-SOLDIER","set":"Final Fantasy Commander","fileName":"Limit_Break_(FINAL_FANTASY_VII).dck","colors":[],"year":2025},
|
||||
{"id":"Limit_Break_Collectors_Edition_(FINAL_FANTASY_VII)","name":"Limit Break Collector's Edition (FINAL FANTASY VII)","commander":"Cloud, Ex-SOLDIER","set":"Final Fantasy Commander","fileName":"Limit_Break_Collectors_Edition_(FINAL_FANTASY_VII).dck","colors":[],"year":2025},
|
||||
{"id":"Revival_Trance_(FINAL_FANTASY_VI)","name":"Revival Trance (FINAL FANTASY VI)","commander":"Terra, Herald of Hope","set":"Final Fantasy Commander","fileName":"Revival_Trance_(FINAL_FANTASY_VI).dck","colors":[],"year":2025},
|
||||
{"id":"Revival_Trance_Collectors_Edition_(FINAL_FANTASY_VI)","name":"Revival Trance Collector's Edition (FINAL FANTASY VI)","commander":"Terra, Herald of Hope","set":"Final Fantasy Commander","fileName":"Revival_Trance_Collectors_Edition_(FINAL_FANTASY_VI).dck","colors":[],"year":2025},
|
||||
{"id":"Scions_&_Spellcraft_(FINAL_FANTASY_XIV)","name":"Scions & Spellcraft (FINAL FANTASY XIV)","commander":"Y'shtola, Night's Blessed","set":"Final Fantasy Commander","fileName":"Scions_&_Spellcraft_(FINAL_FANTASY_XIV).dck","colors":[],"year":2025},
|
||||
{"id":"Scions_&_Spellcraft_Collectors_Edition_(FINAL_FANTASY_XIV)","name":"Scions & Spellcraft Collector's Edition (FINAL FANTASY XIV)","commander":"Y'shtola, Night's Blessed","set":"Final Fantasy Commander","fileName":"Scions_&_Spellcraft_Collectors_Edition_(FINAL_FANTASY_XIV).dck","colors":[],"year":2025},
|
||||
{"id":"Counter_Intelligence","name":"Counter Intelligence","commander":"Inspirit, Flagship Vessel","set":"Edge of Eternities Commander","fileName":"Counter_Intelligence.dck","colors":[],"year":2025},
|
||||
{"id":"World_Shaper","name":"World Shaper","commander":"Hearthhull, the Worldseed","set":"Edge of Eternities Commander","fileName":"World_Shaper.dck","colors":[],"year":2025},
|
||||
{"id":"Blight_Curse","name":"Blight Curse","commander":"Auntie Ool, Cursewretch","set":"Lorwyn Eclipsed Commander","fileName":"Blight_Curse.dck","colors":[],"year":2026},
|
||||
{"id":"Dance_of_the_Elements","name":"Dance of the Elements","commander":"Ashling, the Limitless","set":"Lorwyn Eclipsed Commander","fileName":"Dance_of_the_Elements.dck","colors":[],"year":2026},
|
||||
{"id":"Turtle_Power","name":"Turtle Power!","commander":"Heroes in a Half Shell","set":"Teenage Mutant Ninja Turtles Eternal","fileName":"Turtle_Power.dck","colors":[],"year":2026}
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
-2515
File diff suppressed because it is too large.
Load diff
+83
-204
@@ -4,15 +4,10 @@ Commander AI Lab — Action Labeler
|
||||
Maps raw Forge action records (from DecisionSnapshot JSONL)
|
||||
to one of 8 macro-action labels for supervised policy learning.
|
||||
|
||||
Classification logic (updated):
|
||||
1. Check action.type for unambiguous types (attack, land, pass, hold,
|
||||
cast_commander)
|
||||
2. For "cast" actions:
|
||||
a. If action.card is present, match directly against name databases
|
||||
b. If action.card is absent (Forge omits it), scan the active
|
||||
player's hand against name databases to infer the cast type.
|
||||
Priority: removal > ramp > draw > commander > creature (default)
|
||||
3. Fallback: PASS
|
||||
Classification logic:
|
||||
1. Check action.type (cast, attack, land, cast_commander, pass)
|
||||
2. For "cast" actions, classify by card oracle text / card type keywords
|
||||
3. For ambiguous cases, use the raw log line and card name heuristics
|
||||
|
||||
Macro-actions (from ml.config.scope):
|
||||
0: CAST_CREATURE — Play a creature
|
||||
@@ -46,6 +41,11 @@ logger = logging.getLogger("ml.labeler")
|
||||
# Card Type Keyword Databases
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
# Known removal spells (partial list — expanded from common Commander staples)
|
||||
REMOVAL_KEYWORDS = {
|
||||
"destroy", "exile", "sacrifice", "counter", "bounce", "return.*to.*hand",
|
||||
"-\\d+/-\\d+", "damage to", "fight", "deathtouch",
|
||||
}
|
||||
REMOVAL_CARD_NAMES = {
|
||||
"swords to plowshares", "path to exile", "beast within", "chaos warp",
|
||||
"counterspell", "swan song", "negate", "arcane denial", "doomblade",
|
||||
@@ -62,52 +62,45 @@ REMOVAL_CARD_NAMES = {
|
||||
"decree of pain", "in garruk's wake", "plague wind",
|
||||
"fire covenant", "imprisoned in the moon", "darksteel mutation",
|
||||
"kenrith's transformation", "song of the dryads",
|
||||
"abrade", "disenchant", "naturalize", "wear", "tear",
|
||||
"shattering spree", "shatter", "smash to smithereens",
|
||||
"lightning bolt", "lightning helix", "condemn", "oust",
|
||||
"pongify", "reality shift", "turn to frog", "polymorph",
|
||||
"boros charm", "utter end", "anguished unmaking", "mortify",
|
||||
"putrefy", "maelstrom pulse", "void rend", "render silent",
|
||||
"overwhelming denial", "test of talents", "dispel", "spell pierce",
|
||||
"flusterstorm", "mental misstep", "pact of negation",
|
||||
"opposition agent", "hullbreacher", "narset parter of veils",
|
||||
"grafdigger's cage", "rest in peace", "leyline of the void",
|
||||
"tormod's crypt", "relic of progenitus",
|
||||
}
|
||||
|
||||
# Known draw spells
|
||||
DRAW_KEYWORDS = {
|
||||
"draw", "scry", "look at the top", "reveal.*library",
|
||||
"search your library.*hand", "impulse", "brainstorm",
|
||||
}
|
||||
DRAW_CARD_NAMES = {
|
||||
"rhystic study", "mystic remora", "phyrexian arena", "sylvan library",
|
||||
"harmonize", "blue sun's zenith", "pull from tomorrow",
|
||||
"windfall", "wheel of fortune", "timetwister", "brainstorm",
|
||||
"ponder", "preordain", "opt", "consider", "treasure cruise",
|
||||
"dig through time", "fact or fiction", "night's whisper",
|
||||
"sign in blood", "read the bones", "painful truths",
|
||||
"ambition's cost", "promise of power", "necropotence",
|
||||
"dark confidant", "toski bearer of secrets", "beast whisperer",
|
||||
"guardian project", "the great henge", "skullclamp",
|
||||
"esper sentinel", "archivist of oghma", "mind's eye",
|
||||
"consecrated sphinx", "ancient craving", "faithless looting",
|
||||
"cathartic reunion", "thrill of possibility", "jeska's will",
|
||||
"valakut awakening", "stinging study", "notion thief",
|
||||
"tymna the weaver", "shamanic revelation", "rishkar's expertise",
|
||||
"return of the wildspeaker", "garruk's uprising", "elemental bond",
|
||||
"kindred discovery", "coastal piracy", "reconnaissance mission",
|
||||
"bident of thassa", "distant melody", "slate of ancestry",
|
||||
"wheel of fate", "reforge the soul", "magus of the wheel",
|
||||
"river's rebuke", "prosperity", "intellectual offering",
|
||||
"peer into the abyss", "mind spring", "stroke of genius",
|
||||
"jace's ingenuity", "tidings", "concentrate", "divination",
|
||||
"inspiration", "counsel of the soratami", "accumulated knowledge",
|
||||
"predict", "pressure point", "quicken", "serum visions",
|
||||
"gitaxian probe", "mental note", "thought scour",
|
||||
"dig through time", "fact or fiction", "nights whisper",
|
||||
"nights whisper", "sign in blood", "read the bones",
|
||||
"painful truths", "ambition's cost", "promise of power",
|
||||
"necropotence", "dark confidant", "toski bearer of secrets",
|
||||
"beast whisperer", "guardian project", "the great henge",
|
||||
"skullclamp", "esper sentinel", "archivist of oghma",
|
||||
"mind's eye", "consecrated sphinx", "ancient craving",
|
||||
"faithless looting", "cathartic reunion", "thrill of possibility",
|
||||
"jeska's will", "valakut awakening", "stinging study",
|
||||
"night's whisper", "notion thief", "tymna the weaver",
|
||||
"shamanic revelation", "rishkar's expertise", "return of the wildspeaker",
|
||||
"garruk's uprising", "elemental bond", "kindred discovery",
|
||||
"coastal piracy", "reconnaissance mission", "bident of thassa",
|
||||
}
|
||||
|
||||
# Known ramp cards
|
||||
RAMP_KEYWORDS = {
|
||||
"add {", "add one mana", "mana of any color",
|
||||
"land.*onto the battlefield", "search.*basic land.*battlefield",
|
||||
}
|
||||
RAMP_CARD_NAMES = {
|
||||
"sol ring", "mana crypt", "mana vault", "arcane signet",
|
||||
"commander's sphere", "chromatic lantern", "fellwar stone",
|
||||
"mind stone", "thought vessel", "thran dynamo", "worn powerstone",
|
||||
"gilded lotus", "hedron archive", "everflowing chalice",
|
||||
"coalition relic", "prismatic lens",
|
||||
"coalition relic", "prismatic lens", "signets", # catch-all partial
|
||||
"talisman", # catch-all for talisman cycle
|
||||
"cultivate", "kodama's reach", "rampant growth", "farseek",
|
||||
"nature's lore", "three visits", "skyshroud claim",
|
||||
"explosive vegetation", "migration path", "circuitous route",
|
||||
@@ -119,144 +112,13 @@ RAMP_CARD_NAMES = {
|
||||
"birds of paradise", "llanowar elves", "elvish mystic",
|
||||
"fyndhorn elves", "avacyn's pilgrim", "elves of deep shadow",
|
||||
"bloom tender", "priest of titania", "selvala heart of the wilds",
|
||||
"boreal druid", "green sun's zenith", "eternal witness",
|
||||
"reclamation sage", "mystic snake", "fierce empath",
|
||||
"signal the clans", "crop rotation", "harrow", "kodama's reach",
|
||||
"traverse the ulvenwald", "search for tomorrow", "primal growth",
|
||||
"land grant", "untamed wilds", "rangers path", "rangers' path",
|
||||
"nissa's pilgrimage", "forest grove", "grove of the burnwillows",
|
||||
"cabal stronghold", "urborg tomb of yawgmoth", "urborg, tomb of yawgmoth",
|
||||
"bojuka bog", "ghost quarter", "field of ruin", "strip mine",
|
||||
"wasteland", "tectonic edge", "dust bowl",
|
||||
"worn powerstone", "basalt monolith", "grim monolith",
|
||||
"mana geode", "darksteel ingot", "manalith", "opaline unicorn",
|
||||
"vessel of endless rest", "corrupted grafstone", "dreamstone hedron",
|
||||
"sisay's ring", "ur-golem's eye", "blinkmoth urn",
|
||||
"astral cornucopia", "druids' repository", "black market",
|
||||
"cryptolith rite", "earthcraft", "citanul hierophants",
|
||||
"zendikar resurgent", "mirari's wake", "mana reflection",
|
||||
"vorinclex voice of hunger", "vorinclex, voice of hunger",
|
||||
}
|
||||
|
||||
# Partial-match prefixes (for cycles like Signets, Talismans)
|
||||
RAMP_PARTIAL_PREFIXES = {
|
||||
"talisman", "signet", "locket", "banner", "obelisk",
|
||||
"keyrune", "cluestone", "monolith", "medallion",
|
||||
# Known creature-type indicators
|
||||
CREATURE_KEYWORDS = {
|
||||
"creature", "token", "enters the battlefield",
|
||||
}
|
||||
|
||||
DRAW_PARTIAL_PREFIXES = {
|
||||
"scroll", "tome", "journal",
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Core classification helpers
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def _classify_card_name(name: str) -> Optional[MacroAction]:
|
||||
"""
|
||||
Classify a single card name into a MacroAction, or None if unknown.
|
||||
|
||||
Checks exact matches first, then partial prefix matches.
|
||||
Returns None for unrecognised cards (caller handles the default).
|
||||
"""
|
||||
n = name.lower().strip()
|
||||
if not n:
|
||||
return None
|
||||
|
||||
if n in REMOVAL_CARD_NAMES:
|
||||
return MacroAction.CAST_REMOVAL
|
||||
if n in DRAW_CARD_NAMES:
|
||||
return MacroAction.CAST_DRAW
|
||||
if n in RAMP_CARD_NAMES:
|
||||
return MacroAction.CAST_RAMP
|
||||
|
||||
# Partial prefix match for cycles (Talismans, Signets, etc.)
|
||||
for prefix in RAMP_PARTIAL_PREFIXES:
|
||||
if n.startswith(prefix):
|
||||
return MacroAction.CAST_RAMP
|
||||
for prefix in DRAW_PARTIAL_PREFIXES:
|
||||
if n.startswith(prefix):
|
||||
return MacroAction.CAST_DRAW
|
||||
|
||||
# Substring match (catches "Doom Blade" inside longer names etc.)
|
||||
for known in REMOVAL_CARD_NAMES:
|
||||
if known in n or n in known:
|
||||
return MacroAction.CAST_REMOVAL
|
||||
for known in DRAW_CARD_NAMES:
|
||||
if known in n or n in known:
|
||||
return MacroAction.CAST_DRAW
|
||||
for known in RAMP_CARD_NAMES:
|
||||
if known in n or n in known:
|
||||
return MacroAction.CAST_RAMP
|
||||
|
||||
return None # Unknown → caller defaults to CAST_CREATURE
|
||||
|
||||
|
||||
def _classify_from_hand(
|
||||
players: list,
|
||||
active_seat: int,
|
||||
commander_names: Optional[set] = None,
|
||||
) -> MacroAction:
|
||||
"""
|
||||
Infer the cast action type by scanning the active player's hand.
|
||||
|
||||
When action.card is absent (Forge omits it), the hand snapshot
|
||||
is the best available signal. We scan all hand cards and return
|
||||
the highest-priority match:
|
||||
|
||||
removal > ramp > draw > commander > creature (default)
|
||||
|
||||
Priority rationale: Forge's AI plays the highest-impact available
|
||||
action, so if any removal/ramp/draw spell is in hand during a cast
|
||||
decision, it's the most likely card played.
|
||||
"""
|
||||
if active_seat >= len(players):
|
||||
return MacroAction.CAST_CREATURE
|
||||
|
||||
hand: List[str] = players[active_seat].get("hand", [])
|
||||
command_zone: List[str] = players[active_seat].get("command_zone", [])
|
||||
|
||||
# Build commander name set from command_zone if not provided
|
||||
if commander_names is None:
|
||||
commander_names = {c.lower().strip() for c in command_zone if c}
|
||||
|
||||
found_removal = False
|
||||
found_ramp = False
|
||||
found_draw = False
|
||||
found_commander = False
|
||||
|
||||
for card in hand:
|
||||
card_lower = card.lower().strip()
|
||||
|
||||
# Commander check: hand card matches a command zone card
|
||||
if card_lower in commander_names:
|
||||
found_commander = True
|
||||
continue
|
||||
|
||||
result = _classify_card_name(card_lower)
|
||||
if result == MacroAction.CAST_REMOVAL:
|
||||
found_removal = True
|
||||
elif result == MacroAction.CAST_RAMP:
|
||||
found_ramp = True
|
||||
elif result == MacroAction.CAST_DRAW:
|
||||
found_draw = True
|
||||
|
||||
# Return highest-priority found
|
||||
if found_removal:
|
||||
return MacroAction.CAST_REMOVAL
|
||||
if found_ramp:
|
||||
return MacroAction.CAST_RAMP
|
||||
if found_draw:
|
||||
return MacroAction.CAST_DRAW
|
||||
if found_commander:
|
||||
return MacroAction.CAST_COMMANDER
|
||||
return MacroAction.CAST_CREATURE
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Main label_action entry point
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def label_action(decision: dict) -> int:
|
||||
"""
|
||||
@@ -271,52 +133,74 @@ def label_action(decision: dict) -> int:
|
||||
action = decision.get("action", {})
|
||||
action_type = action.get("type", "").lower()
|
||||
card_name = (action.get("card", "") or "").lower().strip()
|
||||
raw_line = (action.get("raw", "") or "").lower()
|
||||
|
||||
players = decision.get("players", [])
|
||||
active_seat = decision.get("active_seat", 0)
|
||||
|
||||
# ── Unambiguous direct-type matches ──────────────────────
|
||||
# ── Direct type matching ────────────────────────────
|
||||
|
||||
# Commander cast is unambiguous
|
||||
if action_type == "cast_commander":
|
||||
return ACTION_TO_IDX[MacroAction.CAST_COMMANDER]
|
||||
|
||||
# Attack is unambiguous
|
||||
if action_type == "attack":
|
||||
return ACTION_TO_IDX[MacroAction.ATTACK_OPPONENT]
|
||||
|
||||
# Land play → developing mana base = ramp
|
||||
# Land play → classify as CAST_RAMP (developing mana is ramp)
|
||||
if action_type == "land":
|
||||
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
||||
|
||||
# Pass / hold mana — check available mana to distinguish
|
||||
# Pass / hold mana
|
||||
if action_type in ("pass", "hold"):
|
||||
# Check if player had mana open (from state)
|
||||
active_seat = decision.get("active_seat", 0)
|
||||
players = decision.get("players", [])
|
||||
if active_seat < len(players):
|
||||
mana = players[active_seat].get("mana", 0)
|
||||
if mana >= 2:
|
||||
return ACTION_TO_IDX[MacroAction.HOLD_MANA]
|
||||
return ACTION_TO_IDX[MacroAction.PASS]
|
||||
|
||||
# ── Cast actions ──────────────────────────────────────
|
||||
# ── Cast actions: classify by card identity ─────────
|
||||
|
||||
if action_type == "cast":
|
||||
# Path A: action.card is present — classify it directly
|
||||
if card_name:
|
||||
result = _classify_card_name(card_name)
|
||||
if result is not None:
|
||||
return ACTION_TO_IDX[result]
|
||||
# Unrecognised named card → default creature
|
||||
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
|
||||
# 1. Check known card name databases
|
||||
if card_name in REMOVAL_CARD_NAMES or _partial_match(card_name, REMOVAL_CARD_NAMES):
|
||||
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
|
||||
|
||||
# Path B: action.card absent (Forge omits it) — infer from hand
|
||||
inferred = _classify_from_hand(players, active_seat)
|
||||
return ACTION_TO_IDX[inferred]
|
||||
if card_name in DRAW_CARD_NAMES or _partial_match(card_name, DRAW_CARD_NAMES):
|
||||
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
|
||||
|
||||
# ── Fallback ──────────────────────────────────────────
|
||||
if card_name in RAMP_CARD_NAMES or _partial_match(card_name, RAMP_CARD_NAMES):
|
||||
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
||||
|
||||
# 2. Check raw log line for keyword patterns
|
||||
for keyword in REMOVAL_KEYWORDS:
|
||||
if re.search(keyword, raw_line):
|
||||
return ACTION_TO_IDX[MacroAction.CAST_REMOVAL]
|
||||
|
||||
for keyword in DRAW_KEYWORDS:
|
||||
if re.search(keyword, raw_line):
|
||||
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
|
||||
|
||||
for keyword in RAMP_KEYWORDS:
|
||||
if re.search(keyword, raw_line):
|
||||
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
||||
|
||||
# 3. Default: if it's a spell cast and not recognized, assume creature
|
||||
# (most casts in Commander are creatures or creature-adjacent)
|
||||
return ACTION_TO_IDX[MacroAction.CAST_CREATURE]
|
||||
|
||||
# ── Fallback ────────────────────────────────────────
|
||||
return ACTION_TO_IDX[MacroAction.PASS]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# File-level helpers
|
||||
# ══════════════════════════════════════════════════════════
|
||||
def _partial_match(name: str, name_set: set) -> bool:
|
||||
"""Check if card name partially matches any name in the set."""
|
||||
for known in name_set:
|
||||
if known in name or name in known:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
||||
"""
|
||||
@@ -330,7 +214,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
||||
labels = []
|
||||
raw = []
|
||||
|
||||
with open(jsonl_path, "r", encoding="utf-8") as f:
|
||||
with open(jsonl_path, "r") as f:
|
||||
for i, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -348,11 +232,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
||||
|
||||
|
||||
def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
||||
"""Print and return the distribution of action labels.
|
||||
|
||||
Iterates MacroAction enum in index order so the display always
|
||||
matches ACTION_TO_IDX regardless of dict insertion order.
|
||||
"""
|
||||
"""Print and return the distribution of action labels."""
|
||||
dist = {}
|
||||
for action in MacroAction:
|
||||
idx = ACTION_TO_IDX[action]
|
||||
@@ -362,11 +242,10 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
||||
total = len(labels)
|
||||
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
||||
logger.info("─" * 43)
|
||||
# Sort descending by count for readability
|
||||
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
|
||||
pct = (count / total * 100) if total > 0 else 0
|
||||
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
||||
logger.info("─" * 43)
|
||||
logger.info(f"{'─' * 43}")
|
||||
logger.info(f" {'TOTAL':<23} {total:>8}")
|
||||
|
||||
return dist
|
||||
+1
-9
@@ -292,15 +292,7 @@ class DistillationDefaults:
|
||||
ppo_episodes_per_iter: int = 64
|
||||
ppo_lr: float = 3e-4
|
||||
ppo_clip_epsilon: float = 0.2
|
||||
# Increased from 0.01 to 0.04 to prevent premature convergence on the
|
||||
# "pass"/"land" macro-actions. The policy-collapse bug (see
|
||||
# commander-ai-lab-land-only-bug-analysis.md) is exacerbated by a low
|
||||
# entropy bonus: PPO settled on argmax very early in training, so the
|
||||
# model never learned to cast creatures / removal / draw spells even
|
||||
# when they were affordable. A higher entropy bonus keeps the action
|
||||
# distribution flat enough during self-play for those alternatives to
|
||||
# accumulate reward signal.
|
||||
ppo_entropy_coeff: float = 0.04
|
||||
ppo_entropy_coeff: float = 0.01
|
||||
ppo_eval_episodes: int = 100
|
||||
|
||||
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
|
||||
|
||||
+18
-33
@@ -6,9 +6,8 @@ Reads ML decision JSONL files, encodes states, labels actions,
|
||||
and produces a training-ready dataset saved as NPZ.
|
||||
|
||||
Phase 2: Source Awareness (Issue #66)
|
||||
- Scans Forge sim files (ml-decisions-sim-*.jsonl) → source "forge"
|
||||
- Scans PPO self-play (ml-decisions-ppo-*.jsonl) → source "ppo"
|
||||
- Scans DeepSeek files (ml-decisions-ds-*.jsonl) → source "ds"
|
||||
- Scans both Forge sim files (ml-decisions-sim-*.jsonl) and
|
||||
PPO self-play files (ml-decisions-ppo-*.jsonl)
|
||||
- Configurable source_weights for sampling ratios
|
||||
- min_reward_threshold filter for PPO data quality
|
||||
- Each sample tagged with source for downstream analysis
|
||||
@@ -22,7 +21,7 @@ Output NPZ structure:
|
||||
game_ids: object (N,) — game ID for each sample
|
||||
outcomes: object (N,) — game outcome per sample
|
||||
playstyles: object (N,) — deck archetype per sample
|
||||
sources: object (N,) — data source ("forge", "ppo", or "ds")
|
||||
sources: object (N,) — data source ("forge" or "ppo")
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -63,11 +62,11 @@ class DatasetConfig:
|
||||
embeddings_dir: Optional[str] = None
|
||||
max_samples: Optional[int] = None
|
||||
|
||||
# Source weights control sampling ratios between Forge, PPO, and DS data.
|
||||
# ds=4.0 oversamples the 8 DeepSeek files from ~5.8% → ~20% of effective
|
||||
# training tokens, matching their higher signal quality relative to sim data.
|
||||
# Source weights control sampling ratios between Forge and PPO data.
|
||||
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
|
||||
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
|
||||
source_weights: Dict[str, float] = field(
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
|
||||
)
|
||||
|
||||
# Minimum reward threshold for PPO data — only include decisions
|
||||
@@ -82,13 +81,12 @@ class DatasetConfig:
|
||||
|
||||
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
||||
"""
|
||||
Scan results directory for Forge sim, PPO, and DeepSeek decision files.
|
||||
Scan results directory for both Forge sim and PPO decision files.
|
||||
|
||||
Returns:
|
||||
Dict mapping source name to list of JSONL file paths.
|
||||
Keys: "forge" for ml-decisions-sim-*.jsonl
|
||||
"ppo" for ml-decisions-ppo-*.jsonl
|
||||
"ds" for ml-decisions-ds-*.jsonl
|
||||
"""
|
||||
results_path = Path(results_dir)
|
||||
sources = {}
|
||||
@@ -103,16 +101,10 @@ def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
||||
if ppo_files:
|
||||
sources["ppo"] = ppo_files
|
||||
|
||||
# DeepSeek high-quality decisions (upweighted by default)
|
||||
ds_files = sorted(results_path.glob("ml-decisions-ds-*.jsonl"))
|
||||
if ds_files:
|
||||
sources["ds"] = ds_files
|
||||
|
||||
# Legacy files that don't match any known prefix — treat as forge
|
||||
known = set(forge_files + ppo_files + ds_files)
|
||||
# Also pick up legacy files that don't have sim/ppo prefix
|
||||
legacy_files = sorted(
|
||||
f for f in results_path.glob("ml-decisions-*.jsonl")
|
||||
if f not in known
|
||||
if f not in set(forge_files + ppo_files)
|
||||
)
|
||||
if legacy_files:
|
||||
sources.setdefault("forge", []).extend(legacy_files)
|
||||
@@ -131,7 +123,7 @@ def _passes_reward_filter(
|
||||
) -> bool:
|
||||
"""
|
||||
Filter PPO decisions by reward threshold.
|
||||
Forge and DS data always passes. PPO data must meet the threshold.
|
||||
Forge data always passes. PPO data must meet the threshold.
|
||||
"""
|
||||
if source != "ppo":
|
||||
return True
|
||||
@@ -163,7 +155,7 @@ def _apply_source_weights(
|
||||
if weight <= 0:
|
||||
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
|
||||
continue
|
||||
if weight == 1.0:
|
||||
if weight >= 1.0 and weight == 1.0:
|
||||
combined.extend(samples)
|
||||
elif weight < 1.0:
|
||||
# Subsample
|
||||
@@ -201,9 +193,8 @@ def build_dataset(
|
||||
Build complete training dataset from all ML decision files.
|
||||
|
||||
Phase 2 additions:
|
||||
- Scans Forge, PPO, and DeepSeek (ds) JSONL files
|
||||
- Scans both Forge and PPO JSONL files
|
||||
- Applies source_weights for configurable mixing
|
||||
(default: ds=4.0 to oversample high-quality DeepSeek decisions)
|
||||
- Filters PPO data by min_reward_threshold
|
||||
- Tags each sample with its source
|
||||
|
||||
@@ -211,15 +202,14 @@ def build_dataset(
|
||||
results_dir: Path to results/ directory containing JSONL files
|
||||
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
|
||||
max_samples: Cap total samples (None = unlimited)
|
||||
source_weights: Dict of source → sampling weight
|
||||
(default: forge=1.0, ppo=0.0, ds=4.0)
|
||||
source_weights: Dict of source → sampling weight (default: forge=1.0, ppo=0.5)
|
||||
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
|
||||
|
||||
Returns:
|
||||
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
|
||||
"""
|
||||
if source_weights is None:
|
||||
source_weights = {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
|
||||
source_weights = {"forge": 1.0, "ppo": 0.0}
|
||||
|
||||
# Load card embeddings
|
||||
card_index = CardEmbeddingIndex(embeddings_dir)
|
||||
@@ -235,7 +225,7 @@ def build_dataset(
|
||||
|
||||
encoder = StateEncoder(card_index)
|
||||
|
||||
# --- Phase 2 Task 1: Discover files from all sources ---
|
||||
# --- Phase 2 Task 1: Discover files from both sources ---
|
||||
source_files = _discover_jsonl_files(results_dir)
|
||||
if not source_files:
|
||||
logger.error("No ML decision files found in %s", results_dir)
|
||||
@@ -266,7 +256,7 @@ def build_dataset(
|
||||
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
|
||||
file_count = 0
|
||||
|
||||
with open(jsonl_file, "r", encoding="utf-8") as f:
|
||||
with open(jsonl_file, "r") as f:
|
||||
for line_num, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -513,11 +503,7 @@ def main():
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ppo-weight", type=float, default=0.0,
|
||||
help="Sampling weight for PPO self-play data (default: 0.0, disabled)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ds-weight", type=float, default=4.0,
|
||||
help="Sampling weight for DeepSeek decision data (default: 4.0, ~20%% effective share)",
|
||||
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-reward", type=float, default=0.0,
|
||||
@@ -529,7 +515,6 @@ def main():
|
||||
source_weights = {
|
||||
"forge": args.forge_weight,
|
||||
"ppo": args.ppo_weight,
|
||||
"ds": args.ds_weight,
|
||||
}
|
||||
|
||||
# Build dataset
|
||||
|
||||
@@ -523,7 +523,7 @@ class QualityGate:
|
||||
report_path = os.path.join(
|
||||
self.config.reports_dir, f"quality_{timestamp}.json"
|
||||
)
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(metrics, f, indent=2)
|
||||
logger.info("Quality report saved: %s", report_path)
|
||||
|
||||
@@ -582,6 +582,6 @@ class QualityGate:
|
||||
path = os.path.join(self.config.reports_dir, "verdict_history.json")
|
||||
|
||||
history = [v.to_dict() for v in self.verdicts]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with open(path, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
|
||||
@@ -141,17 +141,8 @@ class CardEmbeddingIndex:
|
||||
logger.error("Auto-download failed: %s", e)
|
||||
return None
|
||||
|
||||
def get_embedding(self, card_name) -> np.ndarray:
|
||||
"""Get embedding vector for a card name. Returns zero vector if not found.
|
||||
|
||||
Accepts either a plain string or a dict with a 'name' key (defensive
|
||||
against the LAN client sending structured battlefield objects).
|
||||
"""
|
||||
# Handle dict objects from the LAN client's battlefield_detail
|
||||
if isinstance(card_name, dict):
|
||||
card_name = card_name.get('name', '')
|
||||
if not card_name or not isinstance(card_name, str):
|
||||
return self._zero_vec
|
||||
def get_embedding(self, card_name: str) -> np.ndarray:
|
||||
"""Get embedding vector for a card name. Returns zero vector if not found."""
|
||||
if not self._loaded:
|
||||
return self._zero_vec
|
||||
idx = self._name_to_idx.get(card_name.lower())
|
||||
@@ -164,11 +155,8 @@ class CardEmbeddingIndex:
|
||||
return self._embeddings[idx]
|
||||
return self._zero_vec
|
||||
|
||||
def mean_pool_zone(self, card_names: list) -> np.ndarray:
|
||||
"""Mean-pool embeddings for a list of card names (a game zone).
|
||||
|
||||
Each entry may be a plain string or a dict with a 'name' key.
|
||||
"""
|
||||
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
|
||||
"""Mean-pool embeddings for a list of card names (a game zone)."""
|
||||
if not card_names:
|
||||
return self._zero_vec
|
||||
vectors = [self.get_embedding(name) for name in card_names]
|
||||
@@ -179,24 +167,6 @@ class CardEmbeddingIndex:
|
||||
return self._loaded
|
||||
|
||||
|
||||
def _resolve_cmdr_dmg(raw) -> int:
|
||||
"""Collapse cmdr_dmg to a scalar integer regardless of source format.
|
||||
|
||||
Forge sends cmdr_dmg as a dict keyed by opponent seat string, e.g.
|
||||
{"0": 14, "2": 7}. _build_encoder_snapshot already sums this before
|
||||
passing to predict(), but the live Forge IPC path (and legacy JSONL)
|
||||
may send the raw dict. Summing all values is correct for Commander:
|
||||
the total commander damage received from all opponents is what matters
|
||||
for the life-loss threshold (21).
|
||||
"""
|
||||
if isinstance(raw, dict):
|
||||
return sum(int(v) for v in raw.values())
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
class StateEncoder:
|
||||
"""
|
||||
Encodes a decision snapshot JSON dict into a fixed-size numpy vector.
|
||||
@@ -271,10 +241,6 @@ class StateEncoder:
|
||||
Index 7 priority for total_power:
|
||||
1. p['total_power'] — real Forge value from Step 4 / _build_encoder_snapshot()
|
||||
2. p['creatures'] * 3 — legacy heuristic for old JSONL snapshots
|
||||
|
||||
cmdr_dmg is resolved via _resolve_cmdr_dmg() which handles both the
|
||||
live Forge format (dict keyed by opponent seat) and the pre-collapsed
|
||||
int from _build_encoder_snapshot / JSONL training snapshots.
|
||||
"""
|
||||
features = []
|
||||
|
||||
@@ -284,8 +250,7 @@ class StateEncoder:
|
||||
features.append(life / 40.0)
|
||||
|
||||
# idx 1: commander damage taken
|
||||
# _resolve_cmdr_dmg handles dict (live Forge IPC) and int (JSONL/pre-collapsed)
|
||||
cmdr_dmg = _resolve_cmdr_dmg(p.get("cmdr_dmg", 0))
|
||||
cmdr_dmg = p.get("cmdr_dmg", 0)
|
||||
features.append(cmdr_dmg / 21.0)
|
||||
|
||||
# idx 2: mana available
|
||||
@@ -415,7 +380,7 @@ def encode_decisions_file(
|
||||
states = []
|
||||
raw = []
|
||||
|
||||
with open(jsonl_path, "r", encoding="utf-8") as f:
|
||||
with open(jsonl_path, "r") as f:
|
||||
for i, line in enumerate(f):
|
||||
if max_samples and i >= max_samples:
|
||||
break
|
||||
|
||||
@@ -130,7 +130,7 @@ class EloHistory:
|
||||
def _load(self):
|
||||
if os.path.exists(self.path):
|
||||
try:
|
||||
with open(self.path, "r", encoding="utf-8") as f:
|
||||
with open(self.path, "r") as f:
|
||||
data = json.load(f)
|
||||
self.entries = data.get("entries", [])
|
||||
except Exception:
|
||||
@@ -138,7 +138,7 @@ class EloHistory:
|
||||
|
||||
def save(self):
|
||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||
with open(self.path, "w", encoding="utf-8") as f:
|
||||
with open(self.path, "w") as f:
|
||||
json.dump({"entries": self.entries}, f, indent=2)
|
||||
|
||||
def append(self, generation: int, ratings: Dict[str, float]):
|
||||
|
||||
@@ -280,7 +280,7 @@ class ForgeEvaluator:
|
||||
if not Path(batch_path).exists():
|
||||
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
|
||||
return self._run_synthetic_eval(service)
|
||||
with open(batch_path, encoding="utf-8") as f:
|
||||
with open(batch_path) as f:
|
||||
batch = json.load(f)
|
||||
games_list = batch.get("games", [])
|
||||
if not games_list:
|
||||
@@ -543,6 +543,6 @@ class ForgeEvaluator:
|
||||
"games": [asdict(r) for r in self._records],
|
||||
}
|
||||
os.makedirs(self.cfg.results_dir, exist_ok=True)
|
||||
with open(self._result_path, "w", encoding="utf-8") as f:
|
||||
with open(self._result_path, "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
|
||||
@@ -1,548 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Forge Evaluator (Issue #153)
|
||||
═════════════════════════════════════════════════
|
||||
Reusable eval harness that:
|
||||
1. Starts the policy inference server pointing at a given checkpoint
|
||||
2. Launches Forge games via MultiThreadBatchRunner with the policy
|
||||
server wired in as the AI decision-maker
|
||||
3. Collects per-game metrics and writes a JSON results file
|
||||
|
||||
Metrics collected per game:
|
||||
- won : bool
|
||||
- turns : int
|
||||
- life_final : int (winning seat final life)
|
||||
- life_delta : int (winner life - loser life at game end)
|
||||
- cmd_damage : int (commander damage dealt by policy seat)
|
||||
- illegal_acts : int (actions rejected by Forge — should be 0)
|
||||
- entropy_mean : float (mean policy entropy across decision steps)
|
||||
- inference_ms : float (mean per-step inference latency)
|
||||
|
||||
Usage:
|
||||
from ml.eval.forge_evaluator import ForgeEvaluator, EvalConfig
|
||||
cfg = EvalConfig(
|
||||
checkpoint_path="ml/models/checkpoints/forge-trained/final.pt",
|
||||
forge_jar="forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar",
|
||||
deck_files=["decks/cedh_test_a.dck", "decks/cedh_test_b.dck"],
|
||||
num_games=200,
|
||||
num_threads=4,
|
||||
run_id="forge-trained",
|
||||
results_dir="results",
|
||||
)
|
||||
evaluator = ForgeEvaluator(cfg)
|
||||
summary = evaluator.run()
|
||||
print(summary)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
project_root = str(Path(__file__).parent.parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
logger = logging.getLogger("ml.eval.forge_evaluator")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Configuration
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class EvalConfig:
|
||||
"""All parameters needed for a single evaluation run."""
|
||||
|
||||
# Model
|
||||
checkpoint_path: str = "ml/models/checkpoints/best_policy.pt"
|
||||
embeddings_dir: str = "embeddings"
|
||||
device: str = None # None = auto-detect
|
||||
|
||||
# Forge
|
||||
forge_jar: str = "forge/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar"
|
||||
forge_work_dir: str = "forge"
|
||||
java_path: str = "java"
|
||||
deck_files: List[str] = field(default_factory=list)
|
||||
deck_names: List[str] = field(default_factory=list)
|
||||
policy_server_url: str = "http://localhost:8091"
|
||||
clock_seconds: int = 120
|
||||
|
||||
# Run
|
||||
num_games: int = 200
|
||||
num_threads: int = 4
|
||||
seed: Optional[int] = None
|
||||
run_id: str = ""
|
||||
results_dir: str = "results"
|
||||
|
||||
# Inference
|
||||
greedy: bool = True
|
||||
temperature: float = 1.0
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.run_id:
|
||||
self.run_id = str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Per-game result
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class GameRecord:
|
||||
game_index: int
|
||||
won: bool
|
||||
turns: int
|
||||
life_final_policy: int # life total of policy seat at game end
|
||||
life_final_opponent: int
|
||||
life_delta: int # policy life - opponent life
|
||||
commander_damage_dealt: int
|
||||
illegal_actions: int
|
||||
entropy_mean: float # mean per-step policy entropy
|
||||
inference_ms_mean: float # mean per-step inference latency
|
||||
decision_steps: int # number of decision points in this game
|
||||
error: str = ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Eval summary
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class EvalSummary:
|
||||
run_id: str
|
||||
checkpoint_path: str
|
||||
num_games: int
|
||||
wins: int
|
||||
losses: int
|
||||
win_rate: float
|
||||
win_rate_ci95_low: float
|
||||
win_rate_ci95_high: float
|
||||
avg_turns: float
|
||||
avg_life_delta: float
|
||||
avg_commander_damage: float
|
||||
avg_illegal_actions: float
|
||||
avg_entropy: float
|
||||
avg_inference_ms: float
|
||||
total_decision_steps: int
|
||||
elapsed_wall_s: float
|
||||
games_per_sec: float
|
||||
error_count: int
|
||||
result_file: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"EvalSummary run={self.run_id}\n"
|
||||
f" checkpoint : {self.checkpoint_path}\n"
|
||||
f" games : {self.num_games} ({self.wins}W / {self.losses}L)\n"
|
||||
f" win rate : {self.win_rate:.1%} "
|
||||
f"[{self.win_rate_ci95_low:.1%}, {self.win_rate_ci95_high:.1%}] 95% CI\n"
|
||||
f" avg turns : {self.avg_turns:.1f}\n"
|
||||
f" avg life Δ : {self.avg_life_delta:+.1f}\n"
|
||||
f" avg cmd dmg: {self.avg_commander_damage:.1f}\n"
|
||||
f" illegal : {self.avg_illegal_actions:.2f}/game\n"
|
||||
f" entropy : {self.avg_entropy:.4f}\n"
|
||||
f" latency : {self.avg_inference_ms:.1f} ms/step\n"
|
||||
f" throughput : {self.games_per_sec:.2f} games/sec\n"
|
||||
f" result file: {self.result_file}\n"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Main evaluator
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
class ForgeEvaluator:
|
||||
"""
|
||||
Orchestrates a full eval run:
|
||||
1. Spin up PolicyInferenceService on cfg.policy_server_url port
|
||||
2. Run cfg.num_games Forge games via MultiThreadBatchRunner
|
||||
3. Parse Forge JSONL output for per-game metrics
|
||||
4. Write results to {results_dir}/eval-{run_id}.json
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: EvalConfig):
|
||||
self.cfg = cfg
|
||||
self._records: List[GameRecord] = []
|
||||
self._result_path = os.path.join(
|
||||
cfg.results_dir, f"eval-{cfg.run_id}.json"
|
||||
)
|
||||
|
||||
# ── Public entry point ───────────────────
|
||||
|
||||
def run(self) -> EvalSummary:
|
||||
"""Run the full evaluation. Returns an EvalSummary."""
|
||||
t0 = time.time()
|
||||
os.makedirs(self.cfg.results_dir, exist_ok=True)
|
||||
|
||||
logger.info("[Eval %s] Starting %d-game eval run", self.cfg.run_id, self.cfg.num_games)
|
||||
logger.info("[Eval %s] Checkpoint: %s", self.cfg.run_id, self.cfg.checkpoint_path)
|
||||
|
||||
# Load policy service
|
||||
service = self._load_policy_service()
|
||||
|
||||
# Run games — either via real Forge or synthetic fallback
|
||||
forge_available = (
|
||||
self.cfg.forge_jar
|
||||
and Path(self.cfg.forge_jar).exists()
|
||||
and len(self.cfg.deck_files) >= 3
|
||||
)
|
||||
|
||||
if forge_available:
|
||||
self._records = self._run_forge_games(service)
|
||||
else:
|
||||
logger.warning(
|
||||
"[Eval %s] Forge JAR or decks not found — running synthetic eval.",
|
||||
self.cfg.run_id
|
||||
)
|
||||
self._records = self._run_synthetic_eval(service)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
summary = self._build_summary(elapsed)
|
||||
self._write_results(summary)
|
||||
logger.info("[Eval %s] Done. %s", self.cfg.run_id, summary)
|
||||
return summary
|
||||
|
||||
# ── Policy service setup ─────────────────
|
||||
|
||||
def _load_policy_service(self):
|
||||
try:
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
svc = PolicyInferenceService(
|
||||
checkpoint_dir=str(Path(self.cfg.checkpoint_path).parent),
|
||||
embeddings_dir=self.cfg.embeddings_dir,
|
||||
device=self.cfg.device,
|
||||
)
|
||||
svc.load()
|
||||
ok = svc.reload(self.cfg.checkpoint_path)
|
||||
if not ok:
|
||||
logger.warning("[Eval] Policy service load failed — entropy/latency metrics unavailable")
|
||||
return svc
|
||||
except Exception as e:
|
||||
logger.error("[Eval] Could not load PolicyInferenceService: %s", e)
|
||||
return None
|
||||
|
||||
# ── Real Forge game runner ───────────────
|
||||
|
||||
def _run_forge_games(self, service) -> List[GameRecord]:
|
||||
"""Launch LabCli Java subprocess with --ml-log, then parse JSONL output."""
|
||||
logger.info("[Eval %s] Launching Forge CLI subprocess for %d games", self.cfg.run_id, self.cfg.num_games)
|
||||
deck_args = []
|
||||
for i, df in enumerate(self.cfg.deck_files[:4]):
|
||||
deck_args.extend([f"--deck{i+1}", Path(df).stem])
|
||||
# Resolve Java 17
|
||||
java_exe = self.cfg.java_path
|
||||
for c in [r"C:\Program Files\Eclipse Adoptium\jdk-17.0.18.8-hotspot\bin\java.exe",
|
||||
r"C:\Program Files\Java\jdk-17\bin\java.exe"]:
|
||||
if Path(c).exists():
|
||||
java_exe = c
|
||||
break
|
||||
lab_jar = os.path.join(project_root, "target", "commander-ai-lab-1.0.0-SNAPSHOT.jar")
|
||||
if not Path(lab_jar).exists():
|
||||
logger.error("[Eval] Lab JAR not found at %s -- run mvn package -DskipTests first", lab_jar)
|
||||
return self._run_synthetic_eval(service)
|
||||
cmd = [
|
||||
java_exe, "-jar", lab_jar,
|
||||
"--forge-jar", self.cfg.forge_jar,
|
||||
"--forge-dir", self.cfg.forge_work_dir,
|
||||
*deck_args,
|
||||
"--games", str(self.cfg.num_games),
|
||||
"--threads", str(self.cfg.num_threads),
|
||||
"--clock", str(self.cfg.clock_seconds),
|
||||
"--ml-log",
|
||||
"--output", os.path.join(self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"),
|
||||
]
|
||||
if self.cfg.seed is not None:
|
||||
cmd.extend(["--seed", str(self.cfg.seed)])
|
||||
logger.info("[Eval %s] CMD: %s", self.cfg.run_id, " ".join(cmd))
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=self.cfg.clock_seconds * self.cfg.num_games + 300)
|
||||
if result.returncode != 0:
|
||||
logger.warning("[Eval %s] Forge CLI exit %d: %s", self.cfg.run_id, result.returncode, result.stderr[:500])
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
logger.error("[Eval %s] Forge CLI failed: %s -- falling back to synthetic", self.cfg.run_id, e)
|
||||
return self._run_synthetic_eval(service)
|
||||
return self._parse_batch_json(service)
|
||||
|
||||
def _parse_batch_json(self, service) -> List[GameRecord]:
|
||||
"""Parse the batch JSON output from LabCli Java subprocess."""
|
||||
batch_path = os.path.join(
|
||||
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
|
||||
)
|
||||
if not Path(batch_path).exists():
|
||||
logger.warning("[Eval] Batch JSON not found at %s — falling back to synthetic", batch_path)
|
||||
return self._run_synthetic_eval(service)
|
||||
with open(batch_path, encoding="utf-8") as f:
|
||||
batch = json.load(f)
|
||||
games_list = batch.get("games", [])
|
||||
if not games_list:
|
||||
logger.warning("[Eval] No games in batch JSON — falling back to synthetic")
|
||||
return self._run_synthetic_eval(service)
|
||||
records = []
|
||||
for idx, g in enumerate(games_list):
|
||||
won = g.get("winningSeat", -1) == 0
|
||||
turns = g.get("totalTurns", 0)
|
||||
pr = g.get("playerResults", [])
|
||||
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
|
||||
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
|
||||
records.append(GameRecord(
|
||||
game_index=idx,
|
||||
won=won,
|
||||
turns=turns,
|
||||
life_final_policy=lp,
|
||||
life_final_opponent=lo,
|
||||
life_delta=lp - lo,
|
||||
commander_damage_dealt=0,
|
||||
illegal_actions=0,
|
||||
entropy_mean=0.0,
|
||||
inference_ms_mean=0.0,
|
||||
decision_steps=turns,
|
||||
))
|
||||
if len(records) >= self.cfg.num_games:
|
||||
break
|
||||
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
|
||||
return records
|
||||
|
||||
def _run_forge_via_subprocess(self, service) -> List[GameRecord]:
|
||||
"""Parse JSONL files produced by a separately-launched Forge batch."""
|
||||
records = []
|
||||
results_path = Path(self.cfg.results_dir)
|
||||
pattern = f"ml-decisions-forge-eval-{self.cfg.run_id}*.jsonl"
|
||||
|
||||
all_files = sorted(results_path.glob(pattern))
|
||||
if not all_files:
|
||||
logger.warning("[Eval] No JSONL files found matching %s — falling back to synthetic", pattern)
|
||||
return self._run_synthetic_eval(service)
|
||||
|
||||
from ml.training.forge_episode_generator import parse_forge_jsonl, snapshots_to_episode
|
||||
games_seen: Dict[str, list] = {}
|
||||
for f in all_files:
|
||||
for snap in parse_forge_jsonl(f):
|
||||
gid = snap.get("game_id", "g0")
|
||||
games_seen.setdefault(gid, []).append(snap)
|
||||
|
||||
for idx, (gid, snaps) in enumerate(games_seen.items()):
|
||||
records.append(self._snaps_to_record(idx, gid, snaps, service))
|
||||
if len(records) >= self.cfg.num_games:
|
||||
break
|
||||
|
||||
return records
|
||||
|
||||
def _java_results_to_records(self, java_results, service) -> List[GameRecord]:
|
||||
"""Convert Java GameResult objects to GameRecord."""
|
||||
records = []
|
||||
for jr in java_results:
|
||||
try:
|
||||
won = getattr(jr, "winningSeat", 0) == 0 # seat 0 = policy
|
||||
records.append(GameRecord(
|
||||
game_index=getattr(jr, "gameIndex", len(records)),
|
||||
won=won,
|
||||
turns=getattr(jr, "turns", 0),
|
||||
life_final_policy=getattr(jr, "lifeAtEnd", [40])[0] if hasattr(jr, "lifeAtEnd") else 40,
|
||||
life_final_opponent=getattr(jr, "lifeAtEnd", [40, 40])[1] if hasattr(jr, "lifeAtEnd") else 40,
|
||||
life_delta=0,
|
||||
commander_damage_dealt=0,
|
||||
illegal_actions=getattr(jr, "illegalActions", 0),
|
||||
entropy_mean=0.0,
|
||||
inference_ms_mean=0.0,
|
||||
decision_steps=0,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning("[Eval] Skipping malformed GameResult: %s", e)
|
||||
return records
|
||||
|
||||
# ── Synthetic fallback ───────────────────
|
||||
|
||||
def _run_synthetic_eval(self, service) -> List[GameRecord]:
|
||||
"""Run synthetic games using self_play engine (no Forge required).
|
||||
|
||||
Used when Forge JAR is missing or deck files aren't configured.
|
||||
Metrics like illegal_actions and commander_damage are 0 in this mode.
|
||||
"""
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
||||
|
||||
records = []
|
||||
try:
|
||||
from ml.training.self_play import (
|
||||
create_random_initial_state, apply_action, advance_turn,
|
||||
encode_state_simple, HeuristicPolicy
|
||||
)
|
||||
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
except ImportError as e:
|
||||
logger.error("[Eval] Cannot import self_play: %s", e)
|
||||
return []
|
||||
|
||||
opponent = HeuristicPolicy()
|
||||
agent_has_model = service is not None and service._loaded
|
||||
|
||||
for game_idx in range(self.cfg.num_games):
|
||||
state = create_random_initial_state()
|
||||
entropies, latencies = [], []
|
||||
steps = 0
|
||||
|
||||
while not state.game_over and steps < 60:
|
||||
sv = encode_state_simple(state, 0, "midrange")
|
||||
t0 = time.time()
|
||||
|
||||
if agent_has_model:
|
||||
snap = state.to_snapshot()
|
||||
result = service.predict(
|
||||
snap, playstyle="midrange",
|
||||
greedy=self.cfg.greedy,
|
||||
temperature=self.cfg.temperature,
|
||||
)
|
||||
action_idx = result.get("action_index", 7)
|
||||
probs_map = result.get("probabilities", {})
|
||||
probs = np.array(list(probs_map.values())) if probs_map else np.ones(NUM_ACTIONS) / NUM_ACTIONS
|
||||
else:
|
||||
action_idx = np.random.randint(NUM_ACTIONS)
|
||||
probs = np.ones(NUM_ACTIONS) / NUM_ACTIONS
|
||||
|
||||
latencies.append((time.time() - t0) * 1000)
|
||||
p = np.clip(probs, 1e-9, 1.0)
|
||||
p /= p.sum()
|
||||
entropies.append(float(-np.sum(p * np.log(p))))
|
||||
|
||||
state = apply_action(state, action_idx, 0)
|
||||
if not state.game_over:
|
||||
opp_sv = encode_state_simple(state, 1, "midrange")
|
||||
opp_a, _ = opponent.select_action(opp_sv)
|
||||
state = apply_action(state, opp_a, 1)
|
||||
if not state.game_over:
|
||||
state = advance_turn(state)
|
||||
steps += 1
|
||||
|
||||
won = state.winner == 0
|
||||
lp = state.players[0].life_total
|
||||
lo = state.players[1].life_total
|
||||
records.append(GameRecord(
|
||||
game_index=game_idx,
|
||||
won=won,
|
||||
turns=state.turn,
|
||||
life_final_policy=lp,
|
||||
life_final_opponent=lo,
|
||||
life_delta=lp - lo,
|
||||
commander_damage_dealt=0,
|
||||
illegal_actions=0,
|
||||
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
|
||||
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
|
||||
decision_steps=steps,
|
||||
))
|
||||
|
||||
return records
|
||||
|
||||
# ── JSONL snapshot → GameRecord ──────────
|
||||
|
||||
def _snaps_to_record(self, idx: int, gid: str, snaps: list, service) -> GameRecord:
|
||||
entropies, latencies = [], []
|
||||
won = False
|
||||
turns = len(snaps)
|
||||
cmd_dmg = 0
|
||||
illegal = 0
|
||||
lp, lo = 40, 40
|
||||
|
||||
for snap in snaps:
|
||||
if "outcome" in snap and snap == snaps[-1]:
|
||||
won = snap["outcome"].get("winner_seat", -1) == 0
|
||||
lp = snap["outcome"].get("life_remaining", 40)
|
||||
lo = snap["outcome"].get("opp_life_remaining", 0)
|
||||
cmd_dmg = snap["outcome"].get("commander_damage_dealt", 0)
|
||||
|
||||
if service and service._loaded and "state_vector" in snap:
|
||||
t0 = time.time()
|
||||
result = service.predict(snap, playstyle="midrange", greedy=self.cfg.greedy)
|
||||
latencies.append((time.time() - t0) * 1000)
|
||||
probs_map = result.get("probabilities", {})
|
||||
if probs_map:
|
||||
p = np.clip(list(probs_map.values()), 1e-9, 1.0)
|
||||
p /= p.sum()
|
||||
entropies.append(float(-np.sum(p * np.log(p))))
|
||||
|
||||
return GameRecord(
|
||||
game_index=idx,
|
||||
won=won,
|
||||
turns=turns,
|
||||
life_final_policy=lp,
|
||||
life_final_opponent=lo,
|
||||
life_delta=lp - lo,
|
||||
commander_damage_dealt=cmd_dmg,
|
||||
illegal_actions=illegal,
|
||||
entropy_mean=float(np.mean(entropies)) if entropies else 0.0,
|
||||
inference_ms_mean=float(np.mean(latencies)) if latencies else 0.0,
|
||||
decision_steps=len(snaps),
|
||||
)
|
||||
|
||||
# ── Summary builder ──────────────────────
|
||||
|
||||
def _build_summary(self, elapsed: float) -> EvalSummary:
|
||||
n = len(self._records)
|
||||
if n == 0:
|
||||
return EvalSummary(
|
||||
run_id=self.cfg.run_id,
|
||||
checkpoint_path=self.cfg.checkpoint_path,
|
||||
num_games=0, wins=0, losses=0,
|
||||
win_rate=0.0, win_rate_ci95_low=0.0, win_rate_ci95_high=0.0,
|
||||
avg_turns=0.0, avg_life_delta=0.0, avg_commander_damage=0.0,
|
||||
avg_illegal_actions=0.0, avg_entropy=0.0, avg_inference_ms=0.0,
|
||||
total_decision_steps=0, elapsed_wall_s=elapsed, games_per_sec=0.0,
|
||||
error_count=0, result_file=self._result_path,
|
||||
)
|
||||
|
||||
wins = sum(1 for r in self._records if r.won)
|
||||
losses = n - wins
|
||||
wr = wins / n
|
||||
|
||||
# Wilson score 95% CI
|
||||
z = 1.96
|
||||
denom = 1 + z**2 / n
|
||||
centre = (wr + z**2 / (2 * n)) / denom
|
||||
margin = (z * (wr * (1 - wr) / n + z**2 / (4 * n**2)) ** 0.5) / denom
|
||||
ci_lo = max(0.0, centre - margin)
|
||||
ci_hi = min(1.0, centre + margin)
|
||||
|
||||
errors = sum(1 for r in self._records if r.error)
|
||||
|
||||
return EvalSummary(
|
||||
run_id=self.cfg.run_id,
|
||||
checkpoint_path=self.cfg.checkpoint_path,
|
||||
num_games=n,
|
||||
wins=wins,
|
||||
losses=losses,
|
||||
win_rate=wr,
|
||||
win_rate_ci95_low=ci_lo,
|
||||
win_rate_ci95_high=ci_hi,
|
||||
avg_turns=float(np.mean([r.turns for r in self._records])),
|
||||
avg_life_delta=float(np.mean([r.life_delta for r in self._records])),
|
||||
avg_commander_damage=float(np.mean([r.commander_damage_dealt for r in self._records])),
|
||||
avg_illegal_actions=float(np.mean([r.illegal_actions for r in self._records])),
|
||||
avg_entropy=float(np.mean([r.entropy_mean for r in self._records])),
|
||||
avg_inference_ms=float(np.mean([r.inference_ms_mean for r in self._records])),
|
||||
total_decision_steps=sum(r.decision_steps for r in self._records),
|
||||
elapsed_wall_s=elapsed,
|
||||
games_per_sec=n / max(elapsed, 0.001),
|
||||
error_count=errors,
|
||||
result_file=self._result_path,
|
||||
)
|
||||
|
||||
# ── Result writer ────────────────────────
|
||||
|
||||
def _write_results(self, summary: EvalSummary) -> None:
|
||||
payload = {
|
||||
"summary": asdict(summary),
|
||||
"games": [asdict(r) for r in self._records],
|
||||
}
|
||||
os.makedirs(self.cfg.results_dir, exist_ok=True)
|
||||
with open(self._result_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
logger.info("[Eval %s] Results written to %s", self.cfg.run_id, self._result_path)
|
||||
@@ -417,7 +417,7 @@ def main():
|
||||
|
||||
# Save results
|
||||
output_path = args.output or os.path.join(ckpt_dir, "tournament_results.json")
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(result.to_dict(), f, indent=2)
|
||||
logger.info("Results saved: %s", output_path)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def load_precon_cards(precon_dir: str) -> dict:
|
||||
cards = {"commander": None, "creatures": [], "spells": [], "lands": [], "artifacts": [], "all": []}
|
||||
|
||||
section = "Main"
|
||||
with open(dck_file, "r", encoding="utf-8") as f:
|
||||
with open(dck_file, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("//") or line.startswith("Name="):
|
||||
@@ -380,7 +380,7 @@ def main():
|
||||
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
||||
total_decisions = 0
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
with open(args.output, "w") as f:
|
||||
for game_idx in range(args.games):
|
||||
# Pick two random decks
|
||||
d1_name, d2_name = random.sample(deck_names, 2)
|
||||
|
||||
@@ -68,7 +68,7 @@ def cmd_status(args):
|
||||
for f in jsonl_files:
|
||||
lines = 0
|
||||
try:
|
||||
with open(f, encoding="utf-8") as fh:
|
||||
with open(f) as fh:
|
||||
lines = sum(1 for _ in fh)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -130,7 +130,7 @@ def cmd_inspect(args):
|
||||
|
||||
print(f"\n Inspecting: {filepath.name}\n")
|
||||
count = 0
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
with open(filepath) as f:
|
||||
for i, line in enumerate(f):
|
||||
if i >= args.limit:
|
||||
break
|
||||
@@ -150,7 +150,7 @@ def cmd_inspect(args):
|
||||
except Exception as e:
|
||||
print(f" [{i:>4}] ERROR: {e}")
|
||||
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
with open(filepath) as f:
|
||||
total = sum(1 for _ in f)
|
||||
print(f"\n Showing {count} of {total:,} total decisions.\n")
|
||||
|
||||
|
||||
+13
-48
@@ -52,44 +52,14 @@ from ml.config.scope import (
|
||||
)
|
||||
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
|
||||
|
||||
# Unique logger name so log lines are attributable to this module specifically
|
||||
logger = logging.getLogger("ml.serving.policy_server")
|
||||
|
||||
|
||||
def _unwrap_logits(raw):
|
||||
"""
|
||||
Normalise whatever self.model() returns to a plain logits tensor.
|
||||
|
||||
PolicyNetwork.forward() returns a raw tensor, but the model may be
|
||||
wrapped (DataParallel, torch.compile) or accidentally swapped for
|
||||
PolicyValueNetwork whose forward_with_value() returns (logits, value).
|
||||
A dict return would cause 'dict / float' TypeError on the temperature
|
||||
division -- guard against all three cases here.
|
||||
"""
|
||||
if isinstance(raw, (tuple, list)):
|
||||
# (logits, value) from PolicyValueNetwork or similar
|
||||
return raw[0]
|
||||
if isinstance(raw, dict):
|
||||
# e.g. {"logits": tensor, "value": tensor}
|
||||
for key in ("logits", "policy", "action_logits"):
|
||||
if key in raw:
|
||||
return raw[key]
|
||||
# Fallback: grab the first tensor value in the dict
|
||||
for v in raw.values():
|
||||
if isinstance(v, torch.Tensor):
|
||||
return v
|
||||
raise TypeError(
|
||||
f"model() returned a dict with no recognisable logits key: {list(raw.keys())}"
|
||||
)
|
||||
# Plain tensor -- the normal case
|
||||
return raw
|
||||
logger = logging.getLogger("ml.serving")
|
||||
|
||||
|
||||
class PolicyInferenceService:
|
||||
"""
|
||||
Loads a trained PolicyNetwork and provides action predictions.
|
||||
|
||||
Thread-safe -- uses torch.no_grad() for inference.
|
||||
Thread-safe — uses torch.no_grad() for inference.
|
||||
Designed to be embedded in lab_api.py or run standalone.
|
||||
"""
|
||||
|
||||
@@ -130,7 +100,7 @@ class PolicyInferenceService:
|
||||
"""
|
||||
if not TORCH_AVAILABLE:
|
||||
self._load_error = "PyTorch not installed"
|
||||
logger.error("PyTorch not installed -- cannot serve policy")
|
||||
logger.error("PyTorch not installed — cannot serve policy")
|
||||
return False
|
||||
|
||||
# 1. Load card embeddings
|
||||
@@ -176,7 +146,7 @@ class PolicyInferenceService:
|
||||
|
||||
except Exception as e:
|
||||
self._load_error = str(e)
|
||||
logger.error("Failed to load checkpoint: %s", e, exc_info=True)
|
||||
logger.error("Failed to load checkpoint: %s", e)
|
||||
return False
|
||||
|
||||
def reload(self, checkpoint_path: str = None) -> bool:
|
||||
@@ -208,7 +178,7 @@ class PolicyInferenceService:
|
||||
|
||||
except Exception as e:
|
||||
self._load_error = str(e)
|
||||
logger.error("Hot-reload failed: %s", e, exc_info=True)
|
||||
logger.error("Hot-reload failed: %s", e)
|
||||
return False
|
||||
|
||||
def predict(
|
||||
@@ -254,10 +224,7 @@ class PolicyInferenceService:
|
||||
|
||||
# Run inference
|
||||
with torch.no_grad():
|
||||
raw_output = self.model(state_tensor)
|
||||
# Guard: unwrap tuple/dict returns (DataParallel, torch.compile,
|
||||
# or accidental PolicyValueNetwork swap) before any arithmetic.
|
||||
logits = _unwrap_logits(raw_output)
|
||||
logits = self.model(state_tensor)
|
||||
|
||||
if greedy:
|
||||
action_idx = logits.argmax(dim=-1).item()
|
||||
@@ -287,7 +254,7 @@ class PolicyInferenceService:
|
||||
|
||||
except Exception as e:
|
||||
elapsed_ms = (time.time() - t_start) * 1000
|
||||
logger.error("Inference error: %s", e, exc_info=True)
|
||||
logger.error("Inference error: %s", e)
|
||||
return {
|
||||
"error": str(e),
|
||||
"inference_ms": round(elapsed_ms, 2),
|
||||
@@ -319,9 +286,7 @@ class PolicyInferenceService:
|
||||
|
||||
# Batch inference
|
||||
with torch.no_grad():
|
||||
raw_output = self.model(state_tensor)
|
||||
# Same guard as predict() -- unwrap before softmax/argmax.
|
||||
logits = _unwrap_logits(raw_output)
|
||||
logits = self.model(state_tensor)
|
||||
probs = torch.softmax(logits, dim=-1).cpu().numpy()
|
||||
|
||||
if greedy:
|
||||
@@ -347,7 +312,7 @@ class PolicyInferenceService:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Batch inference error: %s", e, exc_info=True)
|
||||
logger.error("Batch inference error: %s", e)
|
||||
results = [{"error": str(e)}] * len(snapshots)
|
||||
|
||||
return results
|
||||
@@ -366,9 +331,9 @@ class PolicyInferenceService:
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Standalone FastAPI Server
|
||||
# ==========================================================
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def create_standalone_app():
|
||||
"""Create a standalone FastAPI app for the policy server."""
|
||||
@@ -376,7 +341,7 @@ def create_standalone_app():
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI(
|
||||
title="Commander AI Lab -- Policy Server",
|
||||
title="Commander AI Lab — Policy Server",
|
||||
description="Serves macro-action predictions from a trained policy network",
|
||||
version="1.0.0",
|
||||
)
|
||||
@@ -459,7 +424,7 @@ def main():
|
||||
import uvicorn
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Commander AI Lab -- Policy Inference Server"
|
||||
description="Commander AI Lab — Policy Inference Server"
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=8090)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
|
||||
@@ -253,7 +253,7 @@ class DecisionExporter:
|
||||
filename = f"ml-decisions-ppo-{self._batch_id}.jsonl"
|
||||
filepath = os.path.join(self.config.output_dir, filename)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
with open(filepath, "w") as f:
|
||||
for record in self._records:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
|
||||
@@ -759,7 +759,7 @@ class DistillationLoop:
|
||||
"""Save generation history to JSON."""
|
||||
path = os.path.join(self.config.history_dir, "generations.json")
|
||||
history = [g.to_dict() for g in self.generations]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with open(path, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
logger.info(" Generation history saved: %s", path)
|
||||
|
||||
@@ -793,7 +793,7 @@ class DistillationLoop:
|
||||
def _save_summary(self, summary: Dict):
|
||||
"""Save final summary to JSON."""
|
||||
path = os.path.join(self.config.history_dir, "summary.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
with open(path, "w") as f:
|
||||
json.dump(summary, f, indent=2, default=str)
|
||||
logger.info(" Summary saved: %s", path)
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ from ml.training.reward import (
|
||||
compute_intermediate_reward,
|
||||
compute_terminal_reward,
|
||||
)
|
||||
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
|
||||
|
||||
logger = logging.getLogger("ml.forge_episode_generator")
|
||||
|
||||
@@ -52,8 +51,6 @@ class ForgeTransition:
|
||||
log_prob: float
|
||||
value: float
|
||||
done: bool
|
||||
# Raw snapshot retained so the exporter can reconstruct game-state context
|
||||
snapshot: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -64,8 +61,6 @@ class ForgeEpisode:
|
||||
winner: Optional[int] = None
|
||||
total_turns: int = 0
|
||||
deck_names: List[str] = field(default_factory=list)
|
||||
agent_seat: int = 0
|
||||
playstyle: str = "midrange"
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
@@ -75,10 +70,6 @@ class ForgeEpisode:
|
||||
def total_reward(self) -> float:
|
||||
return sum(t.reward for t in self.transitions)
|
||||
|
||||
@property
|
||||
def won(self) -> bool:
|
||||
return self.winner == self.agent_seat
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# JSONL Parser — reads Forge ML decision snapshots
|
||||
@@ -91,7 +82,7 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
|
||||
game_id, turn, phase, active_seat, state, action, outcome
|
||||
"""
|
||||
snapshots = []
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
with open(path, "r") as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -104,44 +95,9 @@ def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
|
||||
return snapshots
|
||||
|
||||
|
||||
def _make_minimal_state(snap: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build a minimal prev/curr state dict from a Forge snapshot.
|
||||
|
||||
compute_intermediate_reward() expects a dict with a 'players' list
|
||||
where each player has 'life_total', 'cards_in_hand',
|
||||
'creatures_on_battlefield', etc. Forge snapshots carry a 'players'
|
||||
array directly; we forward it as-is and fall back to scalar fields
|
||||
when the array is absent.
|
||||
"""
|
||||
if "players" in snap:
|
||||
return {"players": snap["players"]}
|
||||
|
||||
# Fallback: reconstruct a two-player view from flat snapshot fields
|
||||
agent_seat = snap.get("active_seat", 0)
|
||||
opp_seat = 1 - agent_seat
|
||||
players = [None, None]
|
||||
|
||||
def _player(life_key: str, hand_key: str, creatures_key: str) -> Dict:
|
||||
return {
|
||||
"life_total": snap.get(life_key, 40),
|
||||
"cards_in_hand": snap.get(hand_key, 0),
|
||||
"creatures_on_battlefield": snap.get(creatures_key, 0),
|
||||
"cards_in_graveyard": 0,
|
||||
"mana_available": snap.get("mana_available", 0),
|
||||
"commander_damage_taken": 0,
|
||||
"total_power_on_board": 0,
|
||||
}
|
||||
|
||||
players[agent_seat] = _player("agent_life", "agent_hand_size", "agent_creatures")
|
||||
players[opp_seat] = _player("opp_life", "opp_hand_size", "opp_creatures")
|
||||
return {"players": players}
|
||||
|
||||
|
||||
def snapshots_to_episode(
|
||||
snapshots: List[Dict[str, Any]],
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
agent_seat: int = 0,
|
||||
playstyle: str = "midrange",
|
||||
) -> ForgeEpisode:
|
||||
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
|
||||
|
||||
@@ -155,8 +111,6 @@ def snapshots_to_episode(
|
||||
episode = ForgeEpisode(
|
||||
game_id=first.get("game_id", "unknown"),
|
||||
deck_names=first.get("deck_names", []),
|
||||
agent_seat=agent_seat,
|
||||
playstyle=playstyle,
|
||||
)
|
||||
|
||||
rc = reward_config or RewardConfig()
|
||||
@@ -176,26 +130,20 @@ def snapshots_to_episode(
|
||||
except (ValueError, KeyError):
|
||||
action_idx = ACTION_TO_IDX[MacroAction.PASS]
|
||||
|
||||
# Reward — use correct reward.py signatures
|
||||
# Reward
|
||||
is_done = i == len(snapshots) - 1
|
||||
if is_done and "outcome" in snap:
|
||||
outcome = snap["outcome"]
|
||||
reward = compute_terminal_reward(
|
||||
won=outcome.get("won", False),
|
||||
draw=outcome.get("draw", False),
|
||||
timeout=outcome.get("timeout", False),
|
||||
won=snap["outcome"].get("won", False),
|
||||
turns=snap.get("turn", 1),
|
||||
life_remaining=snap["outcome"].get("life_remaining", 0),
|
||||
config=rc,
|
||||
)
|
||||
else:
|
||||
# Build minimal prev/curr dicts for intermediate reward
|
||||
prev_snap = snapshots[i - 1] if i > 0 else snap
|
||||
prev_state = _make_minimal_state(prev_snap)
|
||||
curr_state = _make_minimal_state(snap)
|
||||
reward = compute_intermediate_reward(
|
||||
prev_state=prev_state,
|
||||
curr_state=curr_state,
|
||||
action_taken=action_label,
|
||||
agent_seat=agent_seat,
|
||||
life_delta=snap.get("life_delta", 0),
|
||||
cards_drawn=snap.get("cards_drawn", 0),
|
||||
creatures_played=snap.get("creatures_played", 0),
|
||||
config=rc,
|
||||
)
|
||||
|
||||
@@ -206,7 +154,6 @@ def snapshots_to_episode(
|
||||
log_prob=snap.get("log_prob", 0.0),
|
||||
value=snap.get("value", 0.0),
|
||||
done=is_done,
|
||||
snapshot=snap,
|
||||
))
|
||||
|
||||
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
|
||||
@@ -216,61 +163,6 @@ def snapshots_to_episode(
|
||||
return episode
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# DecisionExporter adapter for Forge episodes
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def _export_forge_episode(episode: ForgeEpisode, exporter: DecisionExporter) -> None:
|
||||
"""Feed a completed ForgeEpisode into the DecisionExporter.
|
||||
|
||||
Translates each ForgeTransition back into the game_state_snapshot
|
||||
format expected by DecisionExporter.record_step(), then finalises
|
||||
the episode so the exporter can apply its win/length gates.
|
||||
|
||||
The raw snapshot stored on each ForgeTransition is used directly
|
||||
when available; otherwise a minimal dict is synthesised from the
|
||||
transition's action index and reward so the schema is always valid.
|
||||
"""
|
||||
exporter.begin_episode(
|
||||
agent_seat=episode.agent_seat,
|
||||
playstyle=episode.playstyle,
|
||||
)
|
||||
|
||||
for t in episode.transitions:
|
||||
if t.snapshot is not None:
|
||||
game_state = _make_minimal_state(t.snapshot)
|
||||
# Carry through turn/phase/active_seat if present
|
||||
game_state["turn"] = t.snapshot.get("turn", 1)
|
||||
game_state["phase"] = t.snapshot.get("phase", "main_1")
|
||||
game_state["active_seat"] = t.snapshot.get("active_seat", episode.agent_seat)
|
||||
else:
|
||||
# Minimal fallback — exporter only needs players list for schema
|
||||
game_state = {
|
||||
"turn": 1,
|
||||
"phase": "main_1",
|
||||
"active_seat": episode.agent_seat,
|
||||
"players": [
|
||||
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
|
||||
"cards_in_graveyard": 0, "mana_available": 0,
|
||||
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
|
||||
{"life_total": 40, "cards_in_hand": 0, "creatures_on_battlefield": 0,
|
||||
"cards_in_graveyard": 0, "mana_available": 0,
|
||||
"commander_damage_taken": 0, "total_power_on_board": 0, "commander_tax": 0},
|
||||
],
|
||||
}
|
||||
|
||||
exporter.record_step(
|
||||
game_state_snapshot=game_state,
|
||||
action_idx=t.action,
|
||||
reward=t.reward,
|
||||
)
|
||||
|
||||
exporter.end_episode(
|
||||
won=episode.won,
|
||||
episode_return=episode.total_reward,
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Async Episode Queue (producer-consumer)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
@@ -379,7 +271,6 @@ async def ppo_consumer(
|
||||
queue: ForgeEpisodeQueue,
|
||||
buffer_size: int = 64,
|
||||
on_batch_ready=None,
|
||||
exporter: Optional[DecisionExporter] = None,
|
||||
) -> int:
|
||||
"""Consumer coroutine: pulls episodes from the queue and batches
|
||||
them for PPO training updates.
|
||||
@@ -388,11 +279,6 @@ async def ppo_consumer(
|
||||
queue: ForgeEpisodeQueue to consume from
|
||||
buffer_size: number of episodes per training batch
|
||||
on_batch_ready: callback(episodes) when a batch is full
|
||||
exporter: Optional DecisionExporter — when provided, every
|
||||
consumed episode is fed into the exporter so winning
|
||||
Forge games accumulate in the distillation JSONL pool.
|
||||
Call exporter.flush() after the pipeline completes to
|
||||
write the JSONL file.
|
||||
|
||||
Returns total episodes consumed.
|
||||
"""
|
||||
@@ -406,14 +292,6 @@ async def ppo_consumer(
|
||||
break
|
||||
continue
|
||||
|
||||
# Feed into distillation exporter (win-gate applied inside exporter)
|
||||
if exporter is not None:
|
||||
try:
|
||||
_export_forge_episode(episode, exporter)
|
||||
except Exception as exc:
|
||||
logger.warning("[CONSUMER] Exporter error for episode %s: %s",
|
||||
episode.game_id, exc)
|
||||
|
||||
batch.append(episode)
|
||||
total_consumed += 1
|
||||
|
||||
@@ -444,24 +322,12 @@ async def run_forge_pipeline(
|
||||
max_queue: int = 256,
|
||||
on_batch_ready=None,
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
exporter: Optional[DecisionExporter] = None,
|
||||
) -> Dict[str, Any]:
|
||||
) -> Dict[str, int]:
|
||||
"""Run the full Forge producer → PPO consumer pipeline.
|
||||
|
||||
The Forge Java batch must be started separately (via forge_runner.py
|
||||
or MultiThreadBatchRunner). This function handles the Python side:
|
||||
reading JSONL output and feeding episodes into the PPO trainer.
|
||||
|
||||
If an exporter is provided, winning Forge episodes are recorded and
|
||||
flushed to a PPO JSONL file at the end of the pipeline run. The
|
||||
output path is returned in the stats dict under "exporter_file".
|
||||
|
||||
Example::
|
||||
|
||||
from ml.training.decision_exporter import DecisionExporter, ExporterConfig
|
||||
exporter = DecisionExporter(ExporterConfig(model_version="gen-1"))
|
||||
stats = await run_forge_pipeline(num_games=128, exporter=exporter)
|
||||
print(stats["exporter_file"]) # data/results/ml-decisions-ppo-<id>.jsonl
|
||||
"""
|
||||
queue = ForgeEpisodeQueue(max_size=max_queue)
|
||||
|
||||
@@ -470,25 +336,16 @@ async def run_forge_pipeline(
|
||||
reward_config=reward_config)
|
||||
)
|
||||
consumer_task = asyncio.create_task(
|
||||
ppo_consumer(queue, buffer_size, on_batch_ready, exporter=exporter)
|
||||
ppo_consumer(queue, buffer_size, on_batch_ready)
|
||||
)
|
||||
|
||||
produced = await producer_task
|
||||
queue.close()
|
||||
consumed = await consumer_task
|
||||
|
||||
# Flush exporter to disk and capture output path
|
||||
exporter_file = None
|
||||
if exporter is not None:
|
||||
exporter_file = exporter.flush()
|
||||
if exporter_file:
|
||||
logger.info("[PIPELINE] Distillation export: %s stats=%s",
|
||||
exporter_file, exporter.stats)
|
||||
|
||||
stats: Dict[str, Any] = {
|
||||
stats = {
|
||||
"produced": produced,
|
||||
"consumed": consumed,
|
||||
"exporter_file": exporter_file,
|
||||
**queue.stats,
|
||||
}
|
||||
logger.info("[PIPELINE] Complete: %s", stats)
|
||||
|
||||
@@ -696,7 +696,7 @@ class PPOTrainer:
|
||||
self.save_checkpoint()
|
||||
|
||||
history_path = os.path.join(cfg.checkpoint_dir, "ppo_history.json")
|
||||
with open(history_path, "w", encoding="utf-8") as f:
|
||||
with open(history_path, "w") as f:
|
||||
json.dump(self.history, f, indent=2, default=str)
|
||||
|
||||
summary = {
|
||||
|
||||
+4
-10
@@ -322,7 +322,7 @@ class SupervisedTrainer:
|
||||
}
|
||||
serializable_history.append(entry)
|
||||
|
||||
with open(history_path, "w", encoding="utf-8") as f:
|
||||
with open(history_path, "w") as f:
|
||||
json.dump(serializable_history, f, indent=2)
|
||||
logger.info(" History saved: %s", history_path)
|
||||
|
||||
@@ -547,7 +547,7 @@ def get_best_device() -> str:
|
||||
return "cpu"
|
||||
if torch.cuda.is_available():
|
||||
name = torch.cuda.get_device_name(0)
|
||||
mem = torch.cuda.get_device_properties(0).total_memory / (1024**3)
|
||||
mem = torch.cuda.get_device_properties(0).total_mem / (1024**3)
|
||||
logger.info(" CUDA device: %s (%.1f GB)", name, mem)
|
||||
return "cuda"
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
@@ -562,12 +562,6 @@ def get_best_device() -> str:
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
# Ensure logger output is visible on console
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Commander AI Lab — Train Policy Network"
|
||||
)
|
||||
@@ -644,7 +638,7 @@ def main():
|
||||
|
||||
# Save evaluation results
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w", encoding="utf-8") as f:
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
logger.info(f" Results saved: {eval_path}")
|
||||
return
|
||||
@@ -702,7 +696,7 @@ def main():
|
||||
print_evaluation(results)
|
||||
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w", encoding="utf-8") as f:
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
logger.info(" Eval results saved: %s", eval_path)
|
||||
else:
|
||||
|
||||
+1
-2
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
|
||||
numGames: int = 100
|
||||
threads: int = 4
|
||||
seed: Optional[int] = None
|
||||
clock: int = 5000
|
||||
clock: int = 300
|
||||
deckSources: Optional[list[Optional[dict]]] = None
|
||||
useLearnedPolicy: bool = False
|
||||
policyStyle: str = "midrange"
|
||||
@@ -170,7 +170,6 @@ class CoachGoalsRequest(BaseModel):
|
||||
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
||||
|
||||
class DeckGenV3SubstituteRequest(BaseModel):
|
||||
card_name: str
|
||||
substitute_name: str
|
||||
+131
-159
@@ -1,159 +1,131 @@
|
||||
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# ── .env bootstrap ────────────────────────────────────────────
|
||||
# Load .env automatically so plain `python script.py` and bare uvicorn
|
||||
# both pick up FORGE_DIR, PRECON_DIR, etc. without a manual load_dotenv
|
||||
# call at every entry point. override=False means real env vars always win.
|
||||
try:
|
||||
from dotenv import load_dotenv as _load_dotenv
|
||||
_here = Path(__file__).resolve().parent
|
||||
for _candidate in [_here, _here.parent, _here.parent.parent]:
|
||||
_env_file = _candidate / ".env"
|
||||
if _env_file.is_file():
|
||||
_load_dotenv(dotenv_path=_env_file, override=False)
|
||||
break
|
||||
except ImportError:
|
||||
pass # python-dotenv not installed; rely on real environment variables
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class Config:
|
||||
"""Runtime configuration -- set via CLI args or env vars."""
|
||||
forge_jar: str = ""
|
||||
forge_dir: str = ""
|
||||
forge_decks_dir: str = ""
|
||||
user_decks_dir: str = ""
|
||||
lab_jar: str = ""
|
||||
precon_dir: str = ""
|
||||
results_dir: str = "results"
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
pplx_api_key: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
|
||||
|
||||
CFG = Config()
|
||||
|
||||
# Pre-populate from environment so uvicorn/ASGI mode works without CLI args.
|
||||
# CLI args (lab_api.py main()) will override these if provided.
|
||||
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
|
||||
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
|
||||
CFG.forge_jar = os.environ.get("FORGE_JAR", "")
|
||||
CFG.forge_dir = os.environ.get("FORGE_DIR", "")
|
||||
CFG.lab_jar = os.environ.get("LAB_JAR", "")
|
||||
# PRECON_DIR / PRECON_DECKS_DIR are aliases -- prefer PRECON_DIR
|
||||
CFG.precon_dir = os.environ.get("PRECON_DIR") or os.environ.get("PRECON_DECKS_DIR", "")
|
||||
CFG.forge_decks_dir = os.environ.get("FORGE_DECKS_DIR", "")
|
||||
CFG.user_decks_dir = os.environ.get(
|
||||
"USER_DECKS_DIR",
|
||||
os.path.join(os.path.dirname(__file__), "..", "user-decks")
|
||||
)
|
||||
|
||||
|
||||
def validate_forge_config() -> None:
|
||||
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
|
||||
if not CFG.forge_dir:
|
||||
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
|
||||
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
|
||||
return
|
||||
base = Path(CFG.forge_dir)
|
||||
a = base / "forge-gui" / "res" / "cardsfolder"
|
||||
b = base / "res" / "cardsfolder"
|
||||
if not a.is_dir() and not b.is_dir():
|
||||
log.warning(
|
||||
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
|
||||
" %s\n %s\n"
|
||||
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
|
||||
CFG.forge_dir, a, b
|
||||
)
|
||||
else:
|
||||
resolved = a if a.is_dir() else b
|
||||
log.info("Forge cardsfolder confirmed at %s", resolved)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# In-Memory State
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class BatchState:
|
||||
def __init__(self, batch_id: str, total_games: int, threads: int):
|
||||
self.batch_id = batch_id
|
||||
self.total_games = total_games
|
||||
self.threads = threads
|
||||
self.completed_games = 0
|
||||
self.running = True
|
||||
self.start_time = datetime.now()
|
||||
self.elapsed_ms = 0
|
||||
self.result_path: Optional[str] = None
|
||||
self.error: Optional[str] = None
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.log_lines: list = []
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
|
||||
import asyncio as _asyncio
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
active_batches_lock = _asyncio.Lock()
|
||||
|
||||
|
||||
COMMANDER_META: dict = {}
|
||||
|
||||
BUILTIN_COMMANDERS: dict = {
|
||||
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
|
||||
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
|
||||
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
|
||||
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
|
||||
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
|
||||
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
|
||||
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
|
||||
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
|
||||
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
|
||||
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
|
||||
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
|
||||
}
|
||||
|
||||
|
||||
def load_commander_meta() -> None:
|
||||
"""Load commander meta mapping from file or fall back to builtins."""
|
||||
meta_path = Path(__file__).parent.parent / "commander-meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
COMMANDER_META.clear()
|
||||
COMMANDER_META.update(json.load(f))
|
||||
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
|
||||
COMMANDER_META.clear()
|
||||
COMMANDER_META.update(BUILTIN_COMMANDERS)
|
||||
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Profiles
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
AI_PROFILES = {
|
||||
"default": {"name": "default", "description": "Balanced — Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
|
||||
"aggro": {"name": "aggro", "description": "Aggressive — attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
|
||||
"control": {"name": "control", "description": "Control — defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
|
||||
"combo": {"name": "combo", "description": "Combo — ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
|
||||
"midrange": {"name": "midrange", "description": "Midrange — flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
|
||||
}
|
||||
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class Config:
|
||||
"""Runtime configuration -- set via CLI args or env vars."""
|
||||
forge_jar: str = ""
|
||||
forge_dir: str = ""
|
||||
forge_decks_dir: str = ""
|
||||
lab_jar: str = ""
|
||||
precon_dir: str = ""
|
||||
results_dir: str = "results"
|
||||
port: int = 8080
|
||||
ximilar_api_key: str = ""
|
||||
pplx_api_key: str = ""
|
||||
anthropic_api_key: str = ""
|
||||
|
||||
|
||||
CFG = Config()
|
||||
# Pre-populate from env so ASGI mode (uvicorn lab_api:app) works without main()
|
||||
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
|
||||
|
||||
def validate_forge_config() -> None:
|
||||
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
|
||||
if not CFG.forge_dir:
|
||||
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
|
||||
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
|
||||
return
|
||||
base = Path(CFG.forge_dir)
|
||||
a = base / "forge-gui" / "res" / "cardsfolder"
|
||||
b = base / "res" / "cardsfolder"
|
||||
if not a.is_dir() and not b.is_dir():
|
||||
log.warning(
|
||||
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
|
||||
" %s\n %s\n"
|
||||
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
|
||||
CFG.forge_dir, a, b
|
||||
)
|
||||
else:
|
||||
resolved = a if a.is_dir() else b
|
||||
log.info("Forge cardsfolder confirmed at %s", resolved)
|
||||
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
|
||||
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# In-Memory State
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
class BatchState:
|
||||
def __init__(self, batch_id: str, total_games: int, threads: int):
|
||||
self.batch_id = batch_id
|
||||
self.total_games = total_games
|
||||
self.threads = threads
|
||||
self.completed_games = 0
|
||||
self.running = True
|
||||
self.start_time = datetime.now()
|
||||
self.elapsed_ms = 0
|
||||
self.result_path: Optional[str] = None
|
||||
self.error: Optional[str] = None
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.log_lines: list = []
|
||||
self.sims_per_sec: float = 0.0
|
||||
|
||||
|
||||
import asyncio as _asyncio
|
||||
active_batches: dict[str, BatchState] = {}
|
||||
active_batches_lock = _asyncio.Lock()
|
||||
|
||||
|
||||
COMMANDER_META: dict = {}
|
||||
|
||||
BUILTIN_COMMANDERS: dict = {
|
||||
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
|
||||
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
|
||||
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
|
||||
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
|
||||
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
|
||||
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
|
||||
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
|
||||
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
|
||||
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
|
||||
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
|
||||
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
|
||||
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
|
||||
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
|
||||
}
|
||||
|
||||
|
||||
def load_commander_meta() -> None:
|
||||
"""Load commander meta mapping from file or fall back to builtins."""
|
||||
meta_path = Path(__file__).parent.parent / "commander-meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
COMMANDER_META.clear()
|
||||
COMMANDER_META.update(json.load(f))
|
||||
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
|
||||
COMMANDER_META.clear()
|
||||
COMMANDER_META.update(BUILTIN_COMMANDERS)
|
||||
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Profiles
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
AI_PROFILES = {
|
||||
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
|
||||
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
|
||||
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
|
||||
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
|
||||
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
|
||||
}
|
||||
+5
-19
@@ -50,7 +50,6 @@ DEFAULT_CONFIG = {
|
||||
"train_lr": 0.001, # learning rate
|
||||
"train_batch_size": 256, # training batch size
|
||||
"train_patience": 15, # early stopping patience
|
||||
"train_ds_weight": 4.0, # DeepSeek source upsampling weight (ds-*.jsonl files)
|
||||
# Java-only settings (ignored for DeepSeek):
|
||||
"java_threads": 4,
|
||||
"java_clock": 300,
|
||||
@@ -280,21 +279,14 @@ def run_update_weights(cfg):
|
||||
return False
|
||||
|
||||
|
||||
def start_training(base_url, epochs, lr, batch_size, patience, ds_weight=4.0):
|
||||
"""Trigger ML training and return immediately.
|
||||
|
||||
ds_weight: upsampling multiplier for DeepSeek decision files
|
||||
(ml-decisions-ds-*.jsonl). Matches the default in
|
||||
DatasetConfig.source_weights added in the recent
|
||||
dataset_builder.py update.
|
||||
"""
|
||||
def start_training(base_url, epochs, lr, batch_size, patience):
|
||||
"""Trigger ML training and return immediately."""
|
||||
resp = api_post(base_url, "/api/ml/train", {
|
||||
"epochs": epochs,
|
||||
"lr": lr,
|
||||
"batchSize": batch_size,
|
||||
"patience": patience,
|
||||
"rebuildDataset": True,
|
||||
"dsWeight": ds_weight,
|
||||
})
|
||||
return resp
|
||||
|
||||
@@ -424,7 +416,6 @@ def run_overnight(cfg):
|
||||
log.info(f" Engine: {engine}")
|
||||
log.info(f" Auto-update weights: {'Yes' if cfg['auto_update_weights'] else 'No'}")
|
||||
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log.info(f" DS upsampling weight: {cfg['train_ds_weight']}x")
|
||||
log.info("")
|
||||
|
||||
while True:
|
||||
@@ -519,8 +510,7 @@ def run_overnight(cfg):
|
||||
|
||||
log.info("Starting ML training pipeline...")
|
||||
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
|
||||
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}, "
|
||||
f"DS Weight: {cfg['train_ds_weight']}x")
|
||||
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
|
||||
|
||||
try:
|
||||
start_training(
|
||||
@@ -529,7 +519,6 @@ def run_overnight(cfg):
|
||||
lr=cfg["train_lr"],
|
||||
batch_size=cfg["train_batch_size"],
|
||||
patience=cfg["train_patience"],
|
||||
ds_weight=cfg["train_ds_weight"],
|
||||
)
|
||||
log.info("Training started, monitoring progress...")
|
||||
success = wait_for_training(base_url)
|
||||
@@ -567,8 +556,8 @@ Examples:
|
||||
# Just run sims, skip both weight update and training:
|
||||
python overnight-run.py --no-train --no-update-weights
|
||||
|
||||
# Custom training settings with higher DS upsampling:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20 --ds-weight 6.0
|
||||
# Custom training settings:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--url", default="http://localhost:8080",
|
||||
@@ -601,8 +590,6 @@ Examples:
|
||||
help="Training batch size (default: 256)")
|
||||
parser.add_argument("--patience", type=int, default=15,
|
||||
help="Early stopping patience (default: 15)")
|
||||
parser.add_argument("--ds-weight", type=float, default=4.0,
|
||||
help="DeepSeek source upsampling weight for training (default: 4.0)")
|
||||
parser.add_argument("--threads", type=int, default=4,
|
||||
help="Java engine threads (default: 4)")
|
||||
parser.add_argument("--clock", type=int, default=300,
|
||||
@@ -624,7 +611,6 @@ Examples:
|
||||
cfg["train_lr"] = args.lr
|
||||
cfg["train_batch_size"] = args.batch_size
|
||||
cfg["train_patience"] = args.patience
|
||||
cfg["train_ds_weight"] = args.ds_weight
|
||||
cfg["java_threads"] = args.threads
|
||||
cfg["java_clock"] = args.clock
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import re
|
||||
|
||||
with open('ml/eval/forge_evaluator.py', 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Fix 1: >= 2 to >= 3
|
||||
content = content.replace('deck_files) >= 2', 'deck_files) >= 3')
|
||||
|
||||
# Fix 2: replace call to _run_forge_via_subprocess with _parse_batch_json
|
||||
content = content.replace(
|
||||
'return self._run_forge_via_subprocess(service)',
|
||||
'return self._parse_batch_json(service)'
|
||||
)
|
||||
|
||||
# Fix 3: Insert _parse_batch_json method before _run_forge_via_subprocess
|
||||
new_method = '''
|
||||
def _parse_batch_json(self, service) -> List[GameRecord]:
|
||||
"""Parse the batch JSON output from LabCli Java subprocess."""
|
||||
batch_path = os.path.join(
|
||||
self.cfg.results_dir, f"eval-{self.cfg.run_id}-batch.json"
|
||||
)
|
||||
if not Path(batch_path).exists():
|
||||
logger.warning("[Eval] Batch JSON not found at %s -- falling back to synthetic", batch_path)
|
||||
return self._run_synthetic_eval(service)
|
||||
with open(batch_path, encoding='utf-8') as f:
|
||||
batch = json.load(f)
|
||||
games_list = batch.get("games", [])
|
||||
if not games_list:
|
||||
logger.warning("[Eval] No games in batch JSON -- falling back to synthetic")
|
||||
return self._run_synthetic_eval(service)
|
||||
records = []
|
||||
for idx, g in enumerate(games_list):
|
||||
won = g.get("winningSeat", -1) == 0
|
||||
turns = g.get("totalTurns", 0)
|
||||
pr = g.get("playerResults", [])
|
||||
lp = pr[0].get("finalLife", 40) if len(pr) > 0 else 40
|
||||
lo = pr[1].get("finalLife", 40) if len(pr) > 1 else 40
|
||||
records.append(GameRecord(
|
||||
game_index=idx,
|
||||
won=won,
|
||||
turns=turns,
|
||||
life_final_policy=lp,
|
||||
life_final_opponent=lo,
|
||||
life_delta=lp - lo,
|
||||
commander_damage_dealt=0,
|
||||
illegal_actions=0,
|
||||
entropy_mean=0.0,
|
||||
inference_ms_mean=0.0,
|
||||
decision_steps=turns,
|
||||
))
|
||||
if len(records) >= self.cfg.num_games:
|
||||
break
|
||||
logger.info("[Eval %s] Parsed %d games from batch JSON", self.cfg.run_id, len(records))
|
||||
return records
|
||||
|
||||
'''
|
||||
|
||||
content = content.replace(
|
||||
' def _run_forge_via_subprocess(',
|
||||
new_method + ' def _run_forge_via_subprocess('
|
||||
)
|
||||
|
||||
with open('ml/eval/forge_evaluator.py', 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
import py_compile
|
||||
py_compile.compile('ml/eval/forge_evaluator.py', doraise=True)
|
||||
print('Syntax OK - patch applied')
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 1 patch: no-op mana pool display functions in lan-client/ui.js
|
||||
Run from anywhere — edit UI_JS_PATH to point at your file.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
UI_JS_PATH = r"D:\ForgeCommander\commander-ai-lab\lan-client\ui.js"
|
||||
|
||||
with open(UI_JS_PATH, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
original_size = len(content)
|
||||
changes = []
|
||||
|
||||
# ── Change 1: no-op renderManaPool ───────────────────────────────────────────
|
||||
old1 = re.search(
|
||||
r'function renderManaPool\(playerIdx\) \{[^}]*?display\.style\.display[^}]*?\}',
|
||||
content, re.DOTALL
|
||||
)
|
||||
if old1:
|
||||
content = content[:old1.start()] + \
|
||||
"function renderManaPool(playerIdx) {\n // mana pool display removed (Phase 1)\n}" + \
|
||||
content[old1.end():]
|
||||
changes.append("renderManaPool -> stub")
|
||||
else:
|
||||
print("WARNING: renderManaPool pattern not matched — skipping")
|
||||
|
||||
# ── Change 2: no-op showManaPool ─────────────────────────────────────────────
|
||||
old2 = re.search(
|
||||
r'function showManaPool\(\) \{.*?\}',
|
||||
content, re.DOTALL
|
||||
)
|
||||
if old2:
|
||||
content = content[:old2.start()] + \
|
||||
"function showManaPool() {\n // mana pool display removed (Phase 1)\n}" + \
|
||||
content[old2.end():]
|
||||
changes.append("showManaPool -> stub")
|
||||
else:
|
||||
print("WARNING: showManaPool pattern not matched — skipping")
|
||||
|
||||
# ── Change 3: remove Mana Auto-Clear row from renderSettingsPanel ─────────────
|
||||
mana_row = (
|
||||
" +\n '<div class=\"settings-row\"><span>Mana Auto-Clear</span>'"
|
||||
" + renderToggle('manaAutoClear', gameState.manaAutoClear) + '</div>';"
|
||||
)
|
||||
if mana_row in content:
|
||||
content = content.replace(mana_row, ";", 1)
|
||||
changes.append("Mana Auto-Clear row removed from renderSettingsPanel")
|
||||
else:
|
||||
print("WARNING: Mana Auto-Clear row not found — skipping")
|
||||
|
||||
# ── Write ─────────────────────────────────────────────────────────────────────
|
||||
if changes:
|
||||
with open(UI_JS_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f"Done. {original_size} -> {len(content)} chars ({len(content)-original_size:+d})")
|
||||
for c in changes:
|
||||
print(f" ✓ {c}")
|
||||
else:
|
||||
print("No changes applied.")
|
||||
@@ -9,6 +9,7 @@ httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
openai>=1.0.0
|
||||
aiosqlite>=0.19.0
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# Anthropic Claude API
|
||||
anthropic>=0.40.0
|
||||
@@ -19,6 +20,9 @@ beautifulsoup4>=4.12.0
|
||||
ollama>=0.2.0
|
||||
scrython>=2.0.0
|
||||
|
||||
# Phase 0: image download script
|
||||
tqdm>=4.65.0
|
||||
|
||||
# Embeddings
|
||||
|
||||
# RAG vector store
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Phase 0 — routes/card_art.py
|
||||
|
||||
FastAPI router for card art endpoints.
|
||||
|
||||
Endpoints:
|
||||
GET /card-art/{scryfall_id}
|
||||
Local-first image redirect.
|
||||
- If local file exists: redirect to /static/card-images/{id}_front.jpg
|
||||
- If not: redirect to Scryfall CDN (no 404, always returns an image)
|
||||
|
||||
GET /card-art/{scryfall_id}/back
|
||||
Same as above but for the back face of DFCs.
|
||||
|
||||
GET /api/card-image-info/{scryfall_id}
|
||||
JSON info: art_url, art_url_back, has_back, is_local
|
||||
|
||||
GET /api/card-image-by-name/{name}
|
||||
Look up by card name (case-insensitive). Returns image info JSON.
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache
|
||||
|
||||
router = APIRouter(tags=["card-art"])
|
||||
|
||||
|
||||
@router.get("/card-art/{scryfall_id}")
|
||||
async def get_card_art(scryfall_id: str):
|
||||
"""
|
||||
Redirect to the front face image for a card.
|
||||
Always returns a redirect — never a 404.
|
||||
Local cache first, Scryfall CDN fallback.
|
||||
"""
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
if result:
|
||||
return RedirectResponse(url=result.front_url, status_code=302)
|
||||
# Ultimate fallback: redirect to CDN directly
|
||||
d1 = scryfall_id[0] if scryfall_id else "0"
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
cdn = f"https://cards.scryfall.io/normal/front/{d1}/{d2}/{scryfall_id}.jpg"
|
||||
return RedirectResponse(url=cdn, status_code=302)
|
||||
|
||||
|
||||
@router.get("/card-art/{scryfall_id}/back")
|
||||
async def get_card_art_back(scryfall_id: str):
|
||||
"""
|
||||
Redirect to the back face image for a DFC.
|
||||
Falls back to CDN back URL if not locally cached.
|
||||
"""
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
if result and result.back_url:
|
||||
return RedirectResponse(url=result.back_url, status_code=302)
|
||||
d1 = scryfall_id[0] if scryfall_id else "0"
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
cdn = f"https://cards.scryfall.io/normal/back/{d1}/{d2}/{scryfall_id}.jpg"
|
||||
return RedirectResponse(url=cdn, status_code=302)
|
||||
|
||||
|
||||
@router.get("/api/card-image-info/{scryfall_id}")
|
||||
async def get_card_image_info(scryfall_id: str):
|
||||
"""
|
||||
Return JSON image info for a card by Scryfall ID.
|
||||
Always returns a result (CDN fallback if not cached locally).
|
||||
"""
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
if result:
|
||||
return result.to_dict()
|
||||
raise HTTPException(status_code=404, detail=f"Card not found: {scryfall_id}")
|
||||
|
||||
|
||||
@router.get("/api/card-image-by-name/{name}")
|
||||
async def get_card_image_by_name(name: str):
|
||||
"""
|
||||
Return JSON image info for a card by name (case-insensitive).
|
||||
Uses the SQLite index. Returns 404 if name not found.
|
||||
"""
|
||||
decoded_name = urllib.parse.unquote(name)
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_name(decoded_name)
|
||||
if result:
|
||||
return result.to_dict()
|
||||
raise HTTPException(status_code=404, detail=f"Card name not found: {decoded_name}")
|
||||
+7
-35
@@ -245,20 +245,6 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest):
|
||||
).fetchone()
|
||||
card_name = ce_row["name"] if ce_row else (req.card_name or "")
|
||||
|
||||
# Ensure card_records has type_line for this card (Scryfall lookup if missing)
|
||||
if card_name:
|
||||
existing_cr = conn.execute(
|
||||
"SELECT type_line FROM card_records WHERE name = ? LIMIT 1", (card_name,)
|
||||
).fetchone()
|
||||
if not existing_cr or not existing_cr["type_line"]:
|
||||
enriched = _enrich_from_scryfall(card_name)
|
||||
if enriched and enriched.get("type_line"):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO card_records (name, type_line, oracle_text, cmc) VALUES (?,?,?,?)",
|
||||
(card_name, enriched["type_line"], enriched.get("oracle_text", ""), enriched.get("cmc", 0))
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
|
||||
(deck_id, req.scryfall_id)
|
||||
@@ -796,12 +782,10 @@ async def export_deck_to_sim(deck_id: int):
|
||||
if not safe_name:
|
||||
safe_name = f"deck_{deck_id}"
|
||||
|
||||
save_dir = (
|
||||
CFG.user_decks_dir
|
||||
or CFG.forge_decks_dir
|
||||
or os.path.join(os.path.dirname(__file__), "..", "user-decks")
|
||||
)
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
save_dir = CFG.forge_decks_dir
|
||||
if not save_dir or not os.path.isdir(save_dir):
|
||||
save_dir = os.path.join(os.path.dirname(__file__), "exported-decks")
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
@@ -888,11 +872,7 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
|
||||
"""
|
||||
deck = _get_deck_or_404(deck_id)
|
||||
body = await request.json()
|
||||
try: # FARMHOUSE_DECK_LINKS_V1b
|
||||
from services.farmhouse_deck_links import expand as _fh_expand
|
||||
text, _fh_name = _fh_expand(body.get('text', '') or body.get('url', ''))
|
||||
except ValueError as _fh_err:
|
||||
return JSONResponse({'error': str(_fh_err)}, status_code=400)
|
||||
text = body.get('text', '')
|
||||
clear_first = body.get('clearFirst', False)
|
||||
|
||||
if not text.strip():
|
||||
@@ -984,12 +964,8 @@ async def import_decklist_new(request: FastAPIRequest):
|
||||
Body: { "text": "...", "name": "My Deck" }
|
||||
"""
|
||||
body = await request.json()
|
||||
try: # FARMHOUSE_DECK_LINKS_V1c
|
||||
from services.farmhouse_deck_links import expand as _fh_expand
|
||||
text, _fh_name = _fh_expand(body.get('text', '') or body.get('url', ''))
|
||||
except ValueError as _fh_err:
|
||||
return JSONResponse({'error': str(_fh_err)}, status_code=400)
|
||||
deck_name = body.get('name', '').strip() or (_fh_name or '')
|
||||
text = body.get('text', '')
|
||||
deck_name = body.get('name', '').strip()
|
||||
|
||||
if not text.strip():
|
||||
return JSONResponse({'error': 'No decklist text provided'}, status_code=400)
|
||||
@@ -1072,10 +1048,6 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
|
||||
Fuzzy-search Scryfall for a card name (used by the scanner pipeline).
|
||||
Returns the raw Scryfall JSON dict, or None on failure.
|
||||
"""
|
||||
from services.farmhouse_deck_links import local_card as _fh_local # FARMHOUSE_LOCAL_LOOKUP
|
||||
_fh_hit = _fh_local(name)
|
||||
if _fh_hit:
|
||||
return _fh_hit
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
encoded = quote(name)
|
||||
|
||||
+7
-8
@@ -861,13 +861,12 @@ async def _call_anthropic_api(messages: list[dict], max_tokens: int = 4096, temp
|
||||
else:
|
||||
if not CFG.anthropic_api_key:
|
||||
raise HTTPException(400, 'Anthropic Claude API key not configured. Set anthropic_api_key env var or --anthropic-key.')
|
||||
base_url = 'https://api.anthropic.com'
|
||||
model = ANTHROPIC_MODEL
|
||||
headers = {
|
||||
base_url = 'https://api.anthropic.com'
|
||||
model = ANTHROPIC_MODEL
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': CFG.anthropic_api_key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
'x-api-key': CFG.anthropic_api_key, 'anthropic-version': '2023-06-01',
|
||||
}
|
||||
# Separate system messages for Anthropic API format
|
||||
system_msgs = [m['content'] for m in messages if m.get('role') == 'system']
|
||||
user_msgs = [m for m in messages if m.get('role') != 'system']
|
||||
@@ -1321,7 +1320,7 @@ async def deck_gen_v3_status():
|
||||
return {
|
||||
'initialized': _coach._deck_gen_v3 is not None,
|
||||
'anthropic_configured': bool(CFG.anthropic_api_key),
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'claude-opus-4-20250514')) if _coach._deck_gen_v3 else None,
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'anthropic_client', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'embeddings_loaded': False,
|
||||
'embedding_cards': 0,
|
||||
'error': _coach._deck_gen_v3_error,
|
||||
@@ -1342,7 +1341,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||
6. Return complete deck with substitution data
|
||||
"""
|
||||
if _coach._deck_gen_v3 is None:
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure the Anthropic API key is configured.')
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
|
||||
|
||||
if not req.commander_name or len(req.commander_name.strip()) < 2:
|
||||
raise HTTPException(400, 'Commander name is required (min 2 chars)')
|
||||
|
||||
+1
-3
@@ -226,7 +226,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
|
||||
with open(batch_path, 'w', encoding='utf-8') as f:
|
||||
with open(batch_path, 'w') as f:
|
||||
json.dump(batch_data, f, indent=2, default=str)
|
||||
logger.info(f'Saved batch results to {batch_path}')
|
||||
|
||||
@@ -516,8 +516,6 @@ def _fetch_deck_card_data(deck_id):
|
||||
'keywords': r[7] or '',
|
||||
'mana_cost': r[8] or '',
|
||||
})
|
||||
from services.farmhouse_sim import fill_card_data as _fh_fill # FARMHOUSE_SIM_DATA_B
|
||||
_fh_fill(card_data)
|
||||
return deck_name, card_data
|
||||
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""Rules-accurate simulation through phase-sim (copied into the app as routes/farmhouse_phase.py by the Dockerfile).
|
||||
|
||||
The phase-rs engine (Rust, headless) plays four-player Commander with a real stack, priority, combat, layers and triggers; the
|
||||
phase-sim service wraps it. This route turns four SAVED decks into the service's request, relays the job, and returns what the
|
||||
engine reported. Nothing is simulated here, and a game that stalled, hit the action cap or panicked is reported, never counted as a win.
|
||||
|
||||
GET /api/farmhouse/phase/status the service's health (is the engine there, how many jobs are waiting)
|
||||
POST /api/farmhouse/phase/run {"deckIds": [4 saved deck ids], "games": 1..50, "seed": 1, "difficulty": "Medium",
|
||||
"seatDifficulty": {"2": "Hard"}} -> {"job": id, "decks": [names]}
|
||||
GET /api/farmhouse/phase/job/{job} status, progress and, when finished, wins by seat, win rate with a 95% interval,
|
||||
warnings (cards the engine did not recognise) and each game's outcome
|
||||
|
||||
Setting: PHASE_SIM_URL (e.g. http://phase-sim.commander-lab.svc.cluster.local:8090). Unset, every endpoint answers 503 and nothing else changes.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.database import _get_db_conn
|
||||
|
||||
router = APIRouter(tags=["farmhouse-phase"])
|
||||
|
||||
|
||||
class PhaseRun(BaseModel):
|
||||
deckIds: List[int]
|
||||
games: int = 1
|
||||
seed: int = 1
|
||||
difficulty: str = "Medium"
|
||||
seatDifficulty: Optional[Dict[str, str]] = None
|
||||
actionCap: Optional[int] = None
|
||||
# Seats (0-3) a MODEL plays instead of phase's built-in AI, and which kind: {"kind": "chat"|"decision", "model": "twin-turbo", "max_calls": 300}.
|
||||
# The model only ever picks among the engine's own legal options; the service falls back to phase's AI on any problem.
|
||||
llmSeats: Optional[List[int]] = None
|
||||
llm: Optional[Dict] = None
|
||||
|
||||
|
||||
def _base():
|
||||
url = os.environ.get("PHASE_SIM_URL", "").rstrip("/")
|
||||
if not url:
|
||||
raise HTTPException(503, "the rules engine service is not configured (PHASE_SIM_URL)")
|
||||
return url
|
||||
|
||||
|
||||
def _call(path, body=None, timeout=20):
|
||||
req = urllib.request.Request(_base() + path, data=None if body is None else json.dumps(body).encode(),
|
||||
headers={"content-type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.load(r)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
detail = json.load(exc).get("error", "")
|
||||
except Exception:
|
||||
detail = ""
|
||||
raise HTTPException(exc.code if exc.code in (400, 404, 413) else 502, detail or "the rules engine service refused the request")
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
raise HTTPException(503, "the rules engine service is not reachable")
|
||||
|
||||
|
||||
def deck_feed(deck_id):
|
||||
"""A saved deck as the phase-sim feed entry: {"name", "commander": [...], "main": [{"name", "count"}]}."""
|
||||
conn = _get_db_conn()
|
||||
deck = conn.execute("SELECT id, name, commander_name FROM decks WHERE id = ?", (deck_id,)).fetchone()
|
||||
if not deck:
|
||||
raise HTTPException(404, "deck %s not found" % deck_id)
|
||||
rows = conn.execute("SELECT card_name, quantity, is_commander FROM deck_cards WHERE deck_id = ?", (deck_id,)).fetchall()
|
||||
commanders, main = [], {}
|
||||
for r in rows:
|
||||
name, qty = (r["card_name"] or "").strip(), int(r["quantity"] or 1)
|
||||
if not name:
|
||||
continue
|
||||
if r["is_commander"]:
|
||||
if name not in commanders:
|
||||
commanders.append(name)
|
||||
else:
|
||||
main[name] = main.get(name, 0) + qty
|
||||
if not commanders and (deck["commander_name"] or "").strip():
|
||||
commanders = [deck["commander_name"].strip()]
|
||||
if not commanders:
|
||||
raise HTTPException(400, "deck %r has no commander set" % deck["name"])
|
||||
main = {n: c for n, c in main.items() if n not in commanders}
|
||||
if not main:
|
||||
raise HTTPException(400, "deck %r has no cards" % deck["name"])
|
||||
return {"name": deck["name"], "commander": commanders, "main": [{"name": n, "count": c} for n, c in sorted(main.items())]}
|
||||
|
||||
|
||||
@router.get("/api/farmhouse/phase/status")
|
||||
def phase_status():
|
||||
return _call("/health", timeout=5)
|
||||
|
||||
|
||||
@router.post("/api/farmhouse/phase/run")
|
||||
def phase_run(req: PhaseRun):
|
||||
if len(req.deckIds) != 4:
|
||||
raise HTTPException(400, "pick exactly 4 decks (one per seat)")
|
||||
feed = [deck_feed(i) for i in req.deckIds]
|
||||
body = {"decks": feed, "games": req.games, "seed": req.seed, "difficulty": req.difficulty}
|
||||
if req.seatDifficulty:
|
||||
body["seat_difficulty"] = req.seatDifficulty
|
||||
if req.actionCap:
|
||||
body["action_cap"] = req.actionCap
|
||||
if req.llmSeats:
|
||||
body["llm_seats"] = req.llmSeats
|
||||
body["llm"] = req.llm or {}
|
||||
out = _call("/run", body)
|
||||
return {"job": out["job"], "decks": [d["name"] for d in feed]}
|
||||
|
||||
|
||||
@router.get("/api/farmhouse/phase/job/{job}")
|
||||
def phase_job(job: str):
|
||||
return _call("/job/" + "".join(c for c in job if c in "0123456789abcdef"), timeout=10)
|
||||
+65
-209
@@ -1,29 +1,11 @@
|
||||
"""Live game session routes.
|
||||
|
||||
Endpoints:
|
||||
POST /api/game/start -- create and launch a live Forge game session
|
||||
POST /api/game/action -- submit a human player action (called by HumanActionBar)
|
||||
GET /api/game/{game_id}/status -- poll session status and last known game state
|
||||
GET /api/game/{game_id}/actions -- Forge IPC: drain pending human actions
|
||||
GET /api/game/sessions -- list active sessions (debug)
|
||||
|
||||
Forge IPC flow
|
||||
--------------
|
||||
1. POST /api/game/start
|
||||
- Validates seats, resolves deck names
|
||||
- Picks a playstyle per AI seat from seat_type (e.g. "AI Aggro" -> "aggro")
|
||||
- Calls run_batch_subprocess(num_games=1, use_learned_policy=True, ...)
|
||||
which builds the Java command with --policy <style> --policyServer http://localhost:8080
|
||||
and spawns the Forge JAR in a background thread
|
||||
- Returns game_id + ws_url immediately (non-blocking)
|
||||
2. Forge JAR (once launched) calls POST /api/policy/decide on every AI decision
|
||||
3. Human actions are queued via POST /api/game/action and drained by
|
||||
GET /api/game/{game_id}/actions (polled by the Forge IPC loop inside the JAR)
|
||||
4. GET /api/game/{game_id}/status returns live log tail + completion state
|
||||
POST /api/game/start — create a new live game session (called by LobbySetupModal)
|
||||
POST /api/game/action — submit a human player action (called by HumanActionBar)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
@@ -31,39 +13,14 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.state import BatchState
|
||||
from services.forge_runner import run_batch_subprocess
|
||||
|
||||
log = logging.getLogger("routes.game")
|
||||
|
||||
router = APIRouter(tags=["game"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playstyle mapping: seat_type string -> policy playstyle token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PLAYSTYLE_MAP: dict[str, str] = {
|
||||
"ai aggro": "aggro",
|
||||
"ai control": "control",
|
||||
"ai combo": "combo",
|
||||
"ai political": "political",
|
||||
"ai midrange": "midrange",
|
||||
"ai": "midrange", # bare "AI" seat -> midrange default
|
||||
"human": None, # human seats skipped for policy
|
||||
}
|
||||
|
||||
|
||||
def _seat_playstyle(seat_type: str) -> Optional[str]:
|
||||
"""Map a SeatConfig.seat_type string to a policy playstyle token.
|
||||
|
||||
Returns None for human seats (no policy needed).
|
||||
"""
|
||||
return _PLAYSTYLE_MAP.get(seat_type.lower().strip(), "midrange")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# Request / Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
class SeatConfig(BaseModel):
|
||||
seat_index: int = 0
|
||||
@@ -74,16 +31,12 @@ class SeatConfig(BaseModel):
|
||||
|
||||
class StartGameRequest(BaseModel):
|
||||
seats: List[SeatConfig] = Field(default_factory=list)
|
||||
clock: int = Field(default=6000, description="Per-game wall-clock limit in seconds (0 = unlimited)")
|
||||
use_learned_policy: bool = Field(default=True, description="Route AI decisions through /api/policy/decide")
|
||||
policy_greedy: bool = Field(default=False, description="Use greedy (argmax) action selection instead of sampling")
|
||||
|
||||
|
||||
class StartGameResponse(BaseModel):
|
||||
game_id: str
|
||||
status: str = "started"
|
||||
ws_url: str
|
||||
policy_enabled: bool = False
|
||||
|
||||
|
||||
class GameActionRequest(BaseModel):
|
||||
@@ -101,139 +54,73 @@ class GameActionResponse(BaseModel):
|
||||
action: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory session store
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# In-memory session store (replace with DB / Redis for persistence)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
# { game_id: { "seats": [...], "status": "active"|"ended"|"error",
|
||||
# "batch_state": BatchState|None, "pending_actions": [...] } }
|
||||
# { game_id: { "seats": [...], "status": "active" | "ended", "pending_actions": [...] } }
|
||||
_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# POST /api/game/start
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/game/start", response_model=StartGameResponse)
|
||||
async def start_game(req: StartGameRequest):
|
||||
"""Create and launch a live Forge game session.
|
||||
"""Create a new live game session.
|
||||
|
||||
Resolves deck names from the seat config, launches the Forge JAR
|
||||
as a background subprocess with --policy routing enabled (unless
|
||||
use_learned_policy=False), and returns a game_id immediately.
|
||||
Called by Unity's LobbySetupModal after the player configures seats.
|
||||
Returns a game_id that Unity uses to connect to the WebSocket at
|
||||
/ws/game/{game_id}.
|
||||
|
||||
The Forge JAR will begin calling POST /api/policy/decide for every
|
||||
AI seat decision as soon as it is up. Human actions are queued via
|
||||
POST /api/game/action and polled by the JAR via GET /api/game/{id}/actions.
|
||||
TODO: Wire Forge engine launch here once the IPC layer is ready.
|
||||
For now, creates a session record and returns the game_id so Unity
|
||||
can connect and receive state pushes from the Forge IPC client.
|
||||
"""
|
||||
if not req.seats:
|
||||
raise HTTPException(status_code=400, detail="At least one seat is required.")
|
||||
|
||||
# Collect deck names from seats that have one, preserving seat order.
|
||||
decks = [s.deck_name for s in req.seats if s.deck_name.strip()]
|
||||
if not decks:
|
||||
raise HTTPException(status_code=400, detail="At least one seat must have a deck_name.")
|
||||
|
||||
# Determine playstyle from the first AI seat (the JAR uses a single global
|
||||
# playstyle for its --policy flag; per-seat styles are a future extension).
|
||||
policy_style = "midrange"
|
||||
for seat in req.seats:
|
||||
ps = _seat_playstyle(seat.seat_type)
|
||||
if ps is not None: # skip human seats
|
||||
policy_style = ps
|
||||
break
|
||||
|
||||
game_id = uuid.uuid4().hex
|
||||
|
||||
# Build a BatchState so the forge_runner watchdog and progress tracking
|
||||
# work the same way they do for overnight batch runs.
|
||||
state = BatchState(batch_id=game_id, total_games=1, threads=1)
|
||||
|
||||
_sessions[game_id] = {
|
||||
"game_id": game_id,
|
||||
"seats": [s.dict() for s in req.seats],
|
||||
"status": "active",
|
||||
"game_id": game_id,
|
||||
"seats": [s.dict() for s in req.seats],
|
||||
"status": "active",
|
||||
"pending_actions": [],
|
||||
"batch_state": state,
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Game session created: game_id=%s seats=%d decks=%s policy=%s greedy=%s",
|
||||
game_id, len(req.seats), decks, policy_style if req.use_learned_policy else "off",
|
||||
req.policy_greedy,
|
||||
)
|
||||
|
||||
# Launch Forge subprocess in the background (non-blocking).
|
||||
# num_games=1 -> single live game, threads=1, no seed.
|
||||
# clock is passed through from the request (default 6000s for live play).
|
||||
asyncio.create_task(
|
||||
_run_and_finalize(
|
||||
game_id=game_id,
|
||||
state=state,
|
||||
decks=decks,
|
||||
clock=req.clock,
|
||||
use_learned_policy=req.use_learned_policy,
|
||||
policy_style=policy_style,
|
||||
policy_greedy=req.policy_greedy,
|
||||
)
|
||||
"Game session created: game_id=%s seats=%d",
|
||||
game_id,
|
||||
len(req.seats),
|
||||
)
|
||||
|
||||
return StartGameResponse(
|
||||
game_id=game_id,
|
||||
status="started",
|
||||
ws_url=f"/ws/game/{game_id}",
|
||||
policy_enabled=req.use_learned_policy,
|
||||
)
|
||||
|
||||
|
||||
async def _run_and_finalize(
|
||||
game_id: str,
|
||||
state: BatchState,
|
||||
decks: list,
|
||||
clock: int,
|
||||
use_learned_policy: bool,
|
||||
policy_style: str,
|
||||
policy_greedy: bool,
|
||||
):
|
||||
"""Background task: run Forge subprocess then mark session ended."""
|
||||
try:
|
||||
await run_batch_subprocess(
|
||||
state=state,
|
||||
decks=decks,
|
||||
num_games=1,
|
||||
threads=1,
|
||||
seed=None,
|
||||
clock=clock,
|
||||
output_path=f"results/live-{game_id}.json",
|
||||
use_learned_policy=use_learned_policy,
|
||||
policy_style=policy_style,
|
||||
policy_greedy=policy_greedy,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Game %s subprocess error: %s", game_id, exc)
|
||||
if game_id in _sessions:
|
||||
_sessions[game_id]["status"] = "error"
|
||||
return
|
||||
|
||||
if game_id in _sessions:
|
||||
_sessions[game_id]["status"] = "ended" if not state.error else "error"
|
||||
log.info(
|
||||
"Game %s finished: status=%s error=%s",
|
||||
game_id, _sessions[game_id]["status"], state.error or "none",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# POST /api/game/action
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/game/action", response_model=GameActionResponse)
|
||||
async def game_action(req: GameActionRequest):
|
||||
"""Submit a human player action during a live game.
|
||||
|
||||
Called by Unity's HumanActionBar when the human seat has priority.
|
||||
The action is queued in the session; the Forge IPC polling loop
|
||||
drains it via GET /api/game/{game_id}/actions.
|
||||
Called by Unity's HumanActionBar when seat 0 (human) has priority.
|
||||
The action is queued in the session's pending_actions list, where
|
||||
the Forge IPC polling loop will pick it up.
|
||||
|
||||
Valid actions:
|
||||
pass_priority — pass priority to the next player
|
||||
end_turn — end the current turn
|
||||
declare_attackers — declare attacking creatures
|
||||
declare_blockers — declare blocking creatures
|
||||
select_target — select a target (requires target_id)
|
||||
"""
|
||||
valid_actions = {
|
||||
"pass_priority", "end_turn",
|
||||
@@ -255,73 +142,47 @@ async def game_action(req: GameActionRequest):
|
||||
|
||||
session = _sessions.get(req.game_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail=f"Game session '{req.game_id}' not found.")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Game session '{req.game_id}' not found.",
|
||||
)
|
||||
|
||||
if session["status"] != "active":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Game '{req.game_id}' is not active (status={session['status']}).",
|
||||
detail=f"Game session '{req.game_id}' is not active (status={session['status']}).",
|
||||
)
|
||||
|
||||
session["pending_actions"].append({
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
action_record = {
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
"target_id": req.target_id,
|
||||
"extra": req.extra,
|
||||
})
|
||||
|
||||
log.info("Action queued: game_id=%s seat=%d action=%s", req.game_id, req.seat, req.action)
|
||||
|
||||
return GameActionResponse(status="ok", game_id=req.game_id, action=req.action)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/game/{game_id}/status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/game/{game_id}/status")
|
||||
async def game_status(game_id: str):
|
||||
"""Return live status, log tail, and completion info for a game session.
|
||||
|
||||
Useful for polling from the frontend or a test script to track
|
||||
whether Forge has finished launching and started calling /decide.
|
||||
"""
|
||||
session = _sessions.get(game_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail=f"Game session '{game_id}' not found.")
|
||||
|
||||
state: Optional[BatchState] = session.get("batch_state")
|
||||
log_tail = []
|
||||
completed = 0
|
||||
total = 1
|
||||
error = None
|
||||
|
||||
if state:
|
||||
log_tail = state.log_lines[-20:] # last 20 lines
|
||||
completed = state.completed_games
|
||||
total = max(state.total_games, 1)
|
||||
error = state.error
|
||||
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"status": session["status"],
|
||||
"seats": session["seats"],
|
||||
"progress": {"completed": completed, "total": total},
|
||||
"log_tail": log_tail,
|
||||
"error": error,
|
||||
"pending_actions": len(session["pending_actions"]),
|
||||
"extra": req.extra,
|
||||
}
|
||||
session["pending_actions"].append(action_record)
|
||||
|
||||
log.info(
|
||||
"Action queued: game_id=%s seat=%d action=%s",
|
||||
req.game_id, req.seat, req.action,
|
||||
)
|
||||
|
||||
return GameActionResponse(
|
||||
status="ok",
|
||||
game_id=req.game_id,
|
||||
action=req.action,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/game/{game_id}/actions (Forge IPC polling)
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# GET /api/game/{game_id}/actions (Forge IPC polling endpoint)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/game/{game_id}/actions")
|
||||
async def poll_actions(game_id: str):
|
||||
"""Drain and return all pending human actions (consume-once).
|
||||
"""Drain and return all pending human actions for a game session.
|
||||
|
||||
Called by the Forge IPC polling loop to consume human player decisions.
|
||||
Clears the queue on each call (consume-once semantics).
|
||||
"""
|
||||
session = _sessions.get(game_id)
|
||||
if session is None:
|
||||
@@ -332,22 +193,17 @@ async def poll_actions(game_id: str):
|
||||
return {"game_id": game_id, "actions": actions}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# GET /api/game/sessions (debug)
|
||||
# ---------------------------------------------------------------------------
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/game/sessions")
|
||||
async def list_sessions():
|
||||
"""List all tracked game sessions (debug)."""
|
||||
"""List active game sessions (debug endpoint)."""
|
||||
return {
|
||||
"count": len(_sessions),
|
||||
"sessions": [
|
||||
{
|
||||
"game_id": gid,
|
||||
"status": s["status"],
|
||||
"seats": len(s["seats"]),
|
||||
"pending": len(s["pending_actions"]),
|
||||
}
|
||||
{"game_id": gid, "status": s["status"], "seats": len(s["seats"])}
|
||||
for gid, s in _sessions.items()
|
||||
],
|
||||
}
|
||||
+10
-126
@@ -14,8 +14,6 @@ Batch simulation & lab management endpoints:
|
||||
GET /api/lab/trends/{deck_name}
|
||||
GET /api/lab/log
|
||||
GET /api/lab/debug-log
|
||||
GET /api/lab/precons
|
||||
GET /api/lab/precons/deck
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -41,7 +39,6 @@ from services.deck_service import parse_dck_file, _load_deck_cards_by_name
|
||||
from services.forge_runner import (
|
||||
build_java_command, run_batch_subprocess, _run_deepseek_batch_thread,
|
||||
)
|
||||
from services.draw_game_analyzer import summarize_draw_games
|
||||
from services.logging import log, log_batch
|
||||
|
||||
router = APIRouter(tags=["lab"])
|
||||
@@ -60,32 +57,6 @@ def _sanitize_floats(obj):
|
||||
return obj
|
||||
|
||||
|
||||
def _inject_win_type(data: dict, win_type_override: str) -> None:
|
||||
"""Stamp winType on every per-game dict that doesn't already have one.
|
||||
|
||||
Called by get_result() after loading the JSON from disk.
|
||||
- Uses setdefault so DeepSeek per-game values (already set in forge_runner.py)
|
||||
are never overwritten.
|
||||
- Also stamps metadata.winType for a quick batch-level summary field.
|
||||
- win_type_override comes from state.win_type_override (Java path) or
|
||||
defaults to "Combat" if the attribute is absent (DeepSeek / old batches).
|
||||
"""
|
||||
if not win_type_override or win_type_override == "Combat":
|
||||
# Still stamp Combat as default so the field is always present
|
||||
win_type_override = "Combat"
|
||||
|
||||
# Batch-level metadata field
|
||||
meta = data.get("metadata")
|
||||
if isinstance(meta, dict):
|
||||
meta.setdefault("winType", win_type_override)
|
||||
|
||||
# Per-game dicts nested under data["decks"][N]["games"][M]
|
||||
for deck in data.get("decks", []):
|
||||
for game in deck.get("games", []):
|
||||
if isinstance(game, dict):
|
||||
game.setdefault("winType", win_type_override)
|
||||
|
||||
|
||||
@router.post("/api/lab/start", response_model=StartResponse)
|
||||
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a new batch simulation run."""
|
||||
@@ -98,7 +69,6 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
if req.threads < 1 or req.threads > 32:
|
||||
raise HTTPException(400, "threads must be 1-32")
|
||||
|
||||
req.threads = min(req.threads, int(os.environ.get("FORGE_MAX_THREADS", "1"))) # FARMHOUSE_FORGE_THREADS
|
||||
if not os.path.exists(CFG.forge_jar):
|
||||
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
|
||||
if not os.path.isdir(CFG.forge_dir):
|
||||
@@ -109,7 +79,7 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
active_batches[batch_id] = state
|
||||
|
||||
os.makedirs(CFG.results_dir, exist_ok=True)
|
||||
output_path = os.path.abspath(os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")) # FARMHOUSE_RESULTS_ABS
|
||||
output_path = os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
|
||||
state.result_path = output_path
|
||||
|
||||
# Auto-export deckbuilder decks to .dck if not already exported
|
||||
@@ -226,19 +196,6 @@ async def get_result(batchId: Optional[str] = None):
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
raise HTTPException(500, f"Failed to read result file: {e}")
|
||||
|
||||
# ── Inject winType into every game dict (fix #175) ──────────────────────
|
||||
win_type_override = getattr(state, "win_type_override", "Combat")
|
||||
_inject_win_type(data, win_type_override)
|
||||
|
||||
# ── Inject draw-game summary (fix #176) ─────────────────────────────────
|
||||
# draw_game_snapshots is populated live by forge_runner._run_process_blocking
|
||||
# from [DRAW] [PARSE-SUMMARY] lines. summarize_draw_games() aggregates them
|
||||
# into a single dict with perDeck breakdowns and a likelyCause field so the
|
||||
# dashboard can immediately show which deck is causing timeout loops.
|
||||
draw_snapshots = getattr(state, "draw_game_snapshots", [])
|
||||
data["drawGames"] = summarize_draw_games(draw_snapshots)
|
||||
|
||||
data = _sanitize_floats(data)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
@@ -260,14 +217,7 @@ async def list_decks():
|
||||
try:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT d.id, d.name, d.commander_name,
|
||||
COALESCE(SUM(dc.quantity), 0) AS total_cards
|
||||
FROM decks d
|
||||
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
|
||||
GROUP BY d.id
|
||||
ORDER BY d.id DESC
|
||||
"""
|
||||
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
db_name = row["name"]
|
||||
@@ -278,7 +228,6 @@ async def list_decks():
|
||||
"source": "deckbuilder",
|
||||
"deck_id": row["id"],
|
||||
"commander": row["commander_name"] or "",
|
||||
"totalCards": row["total_cards"],
|
||||
})
|
||||
seen_names.add(db_name.lower())
|
||||
except Exception as e:
|
||||
@@ -320,31 +269,31 @@ async def list_history():
|
||||
AI_PROFILES = {
|
||||
"default": {
|
||||
"name": "default",
|
||||
"description": "Balanced — Forge's default AI behavior",
|
||||
"description": "Balanced \u2014 Forge's default AI behavior",
|
||||
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
|
||||
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
"aggro": {
|
||||
"name": "aggro",
|
||||
"description": "Aggressive — attacks early, prioritizes damage",
|
||||
"description": "Aggressive \u2014 attacks early, prioritizes damage",
|
||||
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
|
||||
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
|
||||
},
|
||||
"control": {
|
||||
"name": "control",
|
||||
"description": "Control — defensive, removal-heavy, card advantage",
|
||||
"description": "Control \u2014 defensive, removal-heavy, card advantage",
|
||||
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
|
||||
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
|
||||
},
|
||||
"combo": {
|
||||
"name": "combo",
|
||||
"description": "Combo — ramps, digs for pieces, assembles combos",
|
||||
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
|
||||
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
|
||||
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
|
||||
},
|
||||
"midrange": {
|
||||
"name": "midrange",
|
||||
"description": "Midrange — flexible, strong board presence, value-oriented",
|
||||
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
|
||||
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
|
||||
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
@@ -455,7 +404,7 @@ async def get_log(batchId: str):
|
||||
state = active_batches.get(batchId)
|
||||
if not state:
|
||||
raise HTTPException(404, f"Batch {batchId} not found")
|
||||
return {"lines": state.log_lines[-2000:]}
|
||||
return {"lines": state.log_lines[-200:]}
|
||||
|
||||
|
||||
@router.get("/api/lab/debug-log")
|
||||
@@ -479,73 +428,6 @@ async def get_debug_log():
|
||||
return {"path": None, "content": "No debug log found. Run a simulation first."}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Precon deck endpoints (used by lan-client ai-bridge.js)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/api/lab/precons")
|
||||
async def list_precons():
|
||||
"""Return precon deck metadata for the LAN client deck selector."""
|
||||
precon_dir = Path(CFG.precon_dir) if CFG.precon_dir else None
|
||||
if not precon_dir or not precon_dir.is_dir():
|
||||
log.warning(f"Precon dir not found or not configured: {CFG.precon_dir!r}")
|
||||
return {"precons": []}
|
||||
|
||||
precons = []
|
||||
for f in sorted(precon_dir.glob("*.dck")):
|
||||
deck_info = {
|
||||
"fileName": f.name,
|
||||
"deckName": f.stem,
|
||||
"commander": "",
|
||||
"colors": [],
|
||||
"set": "",
|
||||
}
|
||||
# Extract commander name from the [Commander] section
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
in_commander = False
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line == "[Commander]":
|
||||
in_commander = True
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
in_commander = False
|
||||
continue
|
||||
if in_commander:
|
||||
m = re.match(r"^\d+\s+(.+?)(?:\|.+)?$", line)
|
||||
if m:
|
||||
deck_info["commander"] = m.group(1).strip()
|
||||
break
|
||||
except Exception as exc:
|
||||
log.debug(f"Could not parse commander from {f.name}: {exc}")
|
||||
precons.append(deck_info)
|
||||
|
||||
return {"precons": precons}
|
||||
|
||||
|
||||
@router.get("/api/lab/precons/deck")
|
||||
async def get_precon_deck(fileName: str):
|
||||
"""Return the full card list for a specific precon .dck file."""
|
||||
precon_dir = Path(CFG.precon_dir) if CFG.precon_dir else None
|
||||
if not precon_dir or not precon_dir.is_dir():
|
||||
raise HTTPException(404, f"Precon directory not configured or missing: {CFG.precon_dir!r}")
|
||||
|
||||
# Sanitize — prevent path traversal
|
||||
safe_name = Path(fileName).name
|
||||
if not safe_name.endswith(".dck"):
|
||||
raise HTTPException(400, "fileName must end with .dck")
|
||||
deck_path = precon_dir / safe_name
|
||||
|
||||
if not deck_path.exists():
|
||||
raise HTTPException(404, f"Precon deck not found: {safe_name}")
|
||||
|
||||
try:
|
||||
deck_data = parse_dck_file(str(deck_path))
|
||||
return deck_data
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Failed to parse deck: {e}")
|
||||
|
||||
|
||||
@router.post('/api/lab/start-deepseek')
|
||||
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
|
||||
@@ -587,3 +469,5 @@ async def start_batch_deepseek(request: FastAPIRequest, background_tasks: Backgr
|
||||
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
|
||||
'engine': 'deepseek',
|
||||
})
|
||||
|
||||
|
||||
+11
-11
@@ -130,7 +130,7 @@ async def ml_status():
|
||||
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
|
||||
lines = 0
|
||||
try:
|
||||
with open(f, encoding="utf-8") as fh:
|
||||
with open(f) as fh:
|
||||
lines = sum(1 for _ in fh)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -168,7 +168,7 @@ async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
|
||||
raise HTTPException(404, f"ML decisions file not found: {filename}")
|
||||
decisions = []
|
||||
try:
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
with open(filepath) as f:
|
||||
for i, line in enumerate(f):
|
||||
if i < offset:
|
||||
continue
|
||||
@@ -436,7 +436,7 @@ def _run_training_pipeline(
|
||||
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
|
||||
eval_results = evaluate_model(best_model, test_states, test_labels, device)
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w", encoding="utf-8") as f:
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(eval_results, f, indent=2)
|
||||
# Done
|
||||
with _training_lock:
|
||||
@@ -530,7 +530,7 @@ async def ml_data_status():
|
||||
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
|
||||
line_count = 0
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
with open(f, "r") as fh:
|
||||
line_count = sum(1 for _ in fh)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -561,7 +561,7 @@ async def ml_data_status():
|
||||
eval_results = None
|
||||
if eval_path.exists():
|
||||
try:
|
||||
with open(eval_path, "r", encoding="utf-8") as f:
|
||||
with open(eval_path, "r") as f:
|
||||
eval_results = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -705,7 +705,7 @@ def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||
playstyle=playstyle,
|
||||
)
|
||||
output_path = os.path.join(ckpt_dir, "tournament_results.json")
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(result.to_dict(), f, indent=2)
|
||||
with _tournament_lock:
|
||||
_tournament_state.phase = "done"
|
||||
@@ -758,7 +758,7 @@ async def ml_tournament_results():
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
|
||||
if results_path.exists():
|
||||
with open(results_path, "r", encoding="utf-8") as f:
|
||||
with open(results_path, "r") as f:
|
||||
return json.load(f)
|
||||
return {"error": "No tournament results found. Run a tournament first."}
|
||||
|
||||
@@ -798,7 +798,7 @@ async def ml_stats():
|
||||
eval_path = ckpt_dir / "eval_results.json"
|
||||
if eval_path.exists():
|
||||
try:
|
||||
with open(eval_path, "r", encoding="utf-8") as f:
|
||||
with open(eval_path, "r") as f:
|
||||
eval_results = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1057,14 +1057,14 @@ async def ml_distillation_history():
|
||||
|
||||
if history_path.exists():
|
||||
try:
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
with open(history_path, "r") as f:
|
||||
result["generations"] = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if summary_path.exists():
|
||||
try:
|
||||
with open(summary_path, "r", encoding="utf-8") as f:
|
||||
with open(summary_path, "r") as f:
|
||||
result["summary"] = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1203,7 +1203,7 @@ async def ml_elo_history():
|
||||
history_path = project_root / "data" / "elo_history.json"
|
||||
if history_path.exists():
|
||||
try:
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
with open(history_path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+7
-37
@@ -1,5 +1,5 @@
|
||||
"""Commander AI Lab — Policy Decision Routes (Phase 2)
|
||||
════════════════════════════════════════════════════════
|
||||
════════════════════════════════════════════════════
|
||||
Live Forge ↔ policy server IPC endpoints for online training.
|
||||
These endpoints are purpose-built for the real-time decision loop
|
||||
where Forge's Java side calls the Python policy server at each
|
||||
@@ -24,8 +24,7 @@ import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# Unique logger name so log lines are attributable to this module specifically
|
||||
logger = logging.getLogger("routes.policy.decide")
|
||||
logger = logging.getLogger("routes.policy")
|
||||
|
||||
router = APIRouter(prefix="/api/policy", tags=["policy"])
|
||||
|
||||
@@ -311,10 +310,7 @@ async def decide(req: DecideRequest):
|
||||
req.snapshot_schema, req.resolved_turn(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Precomputed vector encode failed — falling back to full encoder",
|
||||
exc_info=True,
|
||||
)
|
||||
logger.warning("Precomputed vector encode failed (%s) — falling back", e)
|
||||
state_vec = None
|
||||
else:
|
||||
state_vec = None
|
||||
@@ -342,24 +338,6 @@ async def decide(req: DecideRequest):
|
||||
if "error" in result:
|
||||
raise HTTPException(500, detail=result["error"])
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Probability diagnostic — logs top-5 actions on every decision.
|
||||
# Remove or downgrade to logger.debug once training looks healthy.
|
||||
# ----------------------------------------------------------------
|
||||
probs = result.get("probabilities", {})
|
||||
if probs:
|
||||
top5 = sorted(probs.items(), key=lambda kv: kv[1], reverse=True)[:5]
|
||||
top5_str = ", ".join(f"{a}:{p:.3f}" for a, p in top5)
|
||||
logger.info(
|
||||
"[decide] turn=%d phase=%s → %s (conf=%.3f) | top5=[%s] | src=%s",
|
||||
req.resolved_turn(),
|
||||
req.phase,
|
||||
result["action"],
|
||||
result["confidence"],
|
||||
top5_str,
|
||||
vector_source,
|
||||
)
|
||||
|
||||
log_prob = 0.0
|
||||
try:
|
||||
conf = result.get("confidence", 0.5)
|
||||
@@ -395,7 +373,7 @@ async def collect_tuples(req: CollectRequest):
|
||||
try:
|
||||
_online_store.add_tuples(req.tuples)
|
||||
except Exception as e:
|
||||
logger.error("Failed to store tuples: %s", e, exc_info=True)
|
||||
logger.error("Failed to store tuples: %s", e)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
@@ -424,7 +402,7 @@ async def submit_reward(req: RewardRequest):
|
||||
winner_seat=req.winner_seat,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to assign reward: %s", e, exc_info=True)
|
||||
logger.error("Failed to assign reward: %s", e)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
@@ -540,24 +518,16 @@ def _infer_from_vector(state_vec: np.ndarray, policy_service, temperature: float
|
||||
|
||||
Mirrors the core of PolicyInferenceService.predict() but accepts an
|
||||
ndarray instead of a snapshot dict, avoiding a second encode() call.
|
||||
|
||||
Uses _unwrap_logits (same helper as predict()) to guard against
|
||||
tuple/dict returns from wrapped or swapped model variants before any
|
||||
arithmetic on the logits tensor.
|
||||
"""
|
||||
import torch
|
||||
from ml.config.scope import NUM_ACTIONS, IDX_TO_ACTION
|
||||
from ml.serving.policy_server import _unwrap_logits
|
||||
|
||||
t_start = time.time()
|
||||
try:
|
||||
state_tensor = torch.from_numpy(state_vec).unsqueeze(0).to(policy_service.device)
|
||||
|
||||
with torch.no_grad():
|
||||
raw_output = policy_service.model(state_tensor)
|
||||
# Guard: unwrap tuple/dict returns (DataParallel, torch.compile,
|
||||
# PolicyValueNetwork swap) before temperature division / argmax.
|
||||
logits = _unwrap_logits(raw_output)
|
||||
logits = policy_service.model(state_tensor)
|
||||
|
||||
if greedy:
|
||||
action_idx = logits.argmax(dim=-1).item()
|
||||
@@ -585,7 +555,7 @@ def _infer_from_vector(state_vec: np.ndarray, policy_service, temperature: float
|
||||
}
|
||||
except Exception as e:
|
||||
elapsed_ms = (time.time() - t_start) * 1000
|
||||
logger.error("Direct inference error in _infer_from_vector: %s", e, exc_info=True)
|
||||
logger.error("Direct inference error: %s", e)
|
||||
return {"error": str(e), "inference_ms": round(elapsed_ms, 2)}
|
||||
|
||||
|
||||
|
||||
+4
-37
@@ -29,31 +29,6 @@ async def list_precons():
|
||||
return {"precons": PRECON_INDEX} # reads current value every call
|
||||
|
||||
|
||||
@router.get("/api/lab/precons/deck")
|
||||
async def get_precon_deck(fileName: str):
|
||||
"""Return the raw decklist content of a precon .dck file.
|
||||
Query: ?fileName=Elven_Empire.dck
|
||||
"""
|
||||
if not fileName:
|
||||
raise HTTPException(400, "fileName query parameter is required")
|
||||
# Sanitize: only allow .dck files, no path traversal
|
||||
if '..' in fileName or '/' in fileName or '\\' in fileName:
|
||||
raise HTTPException(400, "Invalid fileName")
|
||||
if not fileName.endswith('.dck'):
|
||||
raise HTTPException(400, "Only .dck files are supported")
|
||||
|
||||
src = _get_precon_dir() / fileName
|
||||
if not src.exists():
|
||||
raise HTTPException(404, f"Precon not found: {fileName}")
|
||||
|
||||
try:
|
||||
content = src.read_text(encoding='utf-8')
|
||||
except OSError as e:
|
||||
raise HTTPException(500, f"Failed to read deck file: {e}")
|
||||
|
||||
return {"fileName": fileName, "decklist": content}
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/install")
|
||||
async def install_precon(req: dict):
|
||||
"""Install a precon deck to the Forge decks directory.
|
||||
@@ -72,11 +47,7 @@ async def install_precon(req: dict):
|
||||
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
|
||||
|
||||
dst = Path(decks_dir) / file_name
|
||||
try:
|
||||
shutil.copy2(str(src), str(dst))
|
||||
except OSError as e:
|
||||
log.error(f"Failed to copy deck file {file_name}: {e}")
|
||||
raise HTTPException(500, f"Failed to copy deck file: {e}")
|
||||
shutil.copy2(str(src), str(dst))
|
||||
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
return {
|
||||
@@ -107,13 +78,9 @@ async def install_precons_batch(req: dict):
|
||||
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
||||
continue
|
||||
dst = Path(decks_dir) / file_name
|
||||
try:
|
||||
shutil.copy2(str(src), str(dst))
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
|
||||
except OSError as e:
|
||||
log.error(f"Failed to copy deck file {file_name}: {e}")
|
||||
results.append({"fileName": file_name, "installed": False, "error": str(e)})
|
||||
shutil.copy2(str(src), str(dst))
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 0 — build_image_index.py
|
||||
|
||||
Builds (or rebuilds) the SQLite card image index at:
|
||||
data/card_image_index.db
|
||||
|
||||
Schema:
|
||||
card_images(
|
||||
scryfall_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL, -- card name, lowercased for lookup
|
||||
name_raw TEXT NOT NULL, -- original casing
|
||||
front_path TEXT, -- relative path from project root
|
||||
back_path TEXT, -- DFC back face, NULL for single-faced
|
||||
has_back INTEGER DEFAULT 0 -- 1 if DFC
|
||||
)
|
||||
CREATE INDEX idx_name ON card_images(name);
|
||||
|
||||
Usage:
|
||||
python scripts/build_image_index.py
|
||||
python scripts/build_image_index.py --rebuild
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("build_image_index")
|
||||
|
||||
SCRYFALL_BULK_API = "https://api.scryfall.com/bulk-data/default-cards"
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
IMAGE_DIR = PROJECT_ROOT / "static" / "card-images"
|
||||
DB_PATH = PROJECT_ROOT / "data" / "card_image_index.db"
|
||||
|
||||
CREATE_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS card_images (
|
||||
scryfall_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
name_raw TEXT NOT NULL,
|
||||
front_path TEXT,
|
||||
back_path TEXT,
|
||||
has_back INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_name ON card_images(name);
|
||||
"""
|
||||
|
||||
|
||||
def fetch_bulk_cards() -> list[dict]:
|
||||
log.info("Fetching Scryfall bulk manifest...")
|
||||
with urllib.request.urlopen(SCRYFALL_BULK_API, timeout=30) as r:
|
||||
meta = json.loads(r.read())
|
||||
url = meta["download_uri"]
|
||||
log.info("Downloading bulk card data...")
|
||||
with urllib.request.urlopen(url, timeout=120) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def build_rows(cards: list[dict]) -> list[tuple]:
|
||||
"""
|
||||
For each card, check if the image file(s) exist locally.
|
||||
Builds rows for both single-faced and DFC cards.
|
||||
Tokens are included if they have image files.
|
||||
"""
|
||||
rows: list[tuple] = []
|
||||
|
||||
for card in cards:
|
||||
scryfall_id = card.get("id", "")
|
||||
name_raw = card.get("name", "")
|
||||
if not scryfall_id or not name_raw:
|
||||
continue
|
||||
|
||||
name = name_raw.lower().strip()
|
||||
|
||||
front_file = IMAGE_DIR / f"{scryfall_id}_front.jpg"
|
||||
back_file = IMAGE_DIR / f"{scryfall_id}_back.jpg"
|
||||
|
||||
front_path = f"static/card-images/{scryfall_id}_front.jpg" if front_file.exists() else None
|
||||
back_path = f"static/card-images/{scryfall_id}_back.jpg" if back_file.exists() else None
|
||||
has_back = 1 if back_path else 0
|
||||
|
||||
rows.append((scryfall_id, name, name_raw, front_path, back_path, has_back))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def write_db(rows: list[tuple], rebuild: bool) -> None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if rebuild and DB_PATH.exists():
|
||||
DB_PATH.unlink()
|
||||
log.info("Deleted existing database for rebuild.")
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
try:
|
||||
conn.executescript(CREATE_TABLE_SQL)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT OR REPLACE INTO card_images
|
||||
(scryfall_id, name, name_raw, front_path, back_path, has_back)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
rows
|
||||
)
|
||||
conn.commit()
|
||||
log.info("Indexed %d cards into %s", len(rows), DB_PATH)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build SQLite card image index.")
|
||||
parser.add_argument("--rebuild", action="store_true",
|
||||
help="Drop and rebuild the database from scratch")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s"
|
||||
)
|
||||
|
||||
cards = fetch_bulk_cards()
|
||||
log.info("Processing %d card entries...", len(cards))
|
||||
rows = build_rows(cards)
|
||||
log.info("Found %d cards with local images.", len(rows))
|
||||
write_db(rows, args.rebuild)
|
||||
log.info("Index build complete: %s", DB_PATH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -113,7 +113,7 @@ def load_result(path: str) -> dict:
|
||||
print(f"ERROR: File not found: {path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(p, encoding="utf-8") as f:
|
||||
with open(p) as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"ERROR: Invalid JSON in {path}: {e}", file=sys.stderr)
|
||||
@@ -289,7 +289,7 @@ def main() -> int:
|
||||
"passed": delta >= args.min_delta and pval < args.alpha,
|
||||
}
|
||||
Path(args.json_out).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(args.json_out, "w", encoding="utf-8") as jf:
|
||||
with open(args.json_out, "w") as jf:
|
||||
json.dump(out, jf, indent=2)
|
||||
print(f"Comparison JSON written to: {args.json_out}")
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 0 — download_card_images.py
|
||||
|
||||
Bulk-downloads all MTG card images from Scryfall's default_cards bulk export.
|
||||
Stores images at: static/card-images/{scryfall_id}_front.jpg
|
||||
static/card-images/{scryfall_id}_back.jpg (DFCs only)
|
||||
|
||||
Usage:
|
||||
python scripts/download_card_images.py
|
||||
python scripts/download_card_images.py --image-size normal
|
||||
python scripts/download_card_images.py --workers 8 --resume
|
||||
|
||||
Scryfall bulk image domain has no rate limits, so no sleep is needed.
|
||||
See: https://scryfall.com/docs/api/bulk-data
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError:
|
||||
tqdm = None
|
||||
|
||||
log = logging.getLogger("download_card_images")
|
||||
|
||||
SCRYFALL_BULK_API = "https://api.scryfall.com/bulk-data/default-cards"
|
||||
DEFAULT_IMAGE_SIZE = "normal" # 488x680px — ideal for Unity card rendering
|
||||
DEFAULT_WORKERS = 6
|
||||
|
||||
# Scryfall requires a descriptive User-Agent — returns HTTP 400 without it.
|
||||
# See: https://scryfall.com/docs/api#making-requests
|
||||
USER_AGENT = "CommanderAILab/1.0 (https://github.com/KoalaTrapLord/commander-ai-lab)"
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
OUTPUT_DIR = PROJECT_ROOT / "static" / "card-images"
|
||||
|
||||
|
||||
def _open(url: str, timeout: int = 30):
|
||||
"""urllib.request.urlopen with required Scryfall User-Agent header."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def fetch_bulk_download_url(image_size: str) -> str:
|
||||
"""Get the Scryfall bulk data download URL for default_cards."""
|
||||
log.info("Fetching Scryfall bulk data manifest...")
|
||||
with _open(SCRYFALL_BULK_API, timeout=30) as resp:
|
||||
meta = json.loads(resp.read())
|
||||
return meta["download_uri"]
|
||||
|
||||
|
||||
def load_bulk_data(download_url: str) -> list[dict]:
|
||||
"""Stream-download and parse the bulk JSON (can be ~100MB)."""
|
||||
log.info("Downloading bulk card data from %s ...", download_url)
|
||||
with _open(download_url, timeout=120) as resp:
|
||||
cards = json.loads(resp.read())
|
||||
log.info("Loaded %d card entries.", len(cards))
|
||||
return cards
|
||||
|
||||
|
||||
def get_image_jobs(cards: list[dict], image_size: str, resume: bool) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Build a list of (url, dest_path) tuples to download.
|
||||
Handles:
|
||||
- Single-faced cards: {scryfall_id}_front.jpg
|
||||
- Double-faced cards (DFCs): {scryfall_id}_front.jpg + {scryfall_id}_back.jpg
|
||||
- Tokens: uses image_uris if present, else skips gracefully
|
||||
"""
|
||||
jobs: list[tuple[str, str]] = []
|
||||
|
||||
for card in cards:
|
||||
scryfall_id = card.get("id", "")
|
||||
if not scryfall_id:
|
||||
continue
|
||||
|
||||
# Single-faced or meld cards
|
||||
if "image_uris" in card:
|
||||
url = card["image_uris"].get(image_size)
|
||||
if url:
|
||||
dest = str(OUTPUT_DIR / f"{scryfall_id}_front.jpg")
|
||||
if not resume or not Path(dest).exists():
|
||||
jobs.append((url, dest))
|
||||
|
||||
# Double-faced cards (DFCs): modal, transform, adventure, etc.
|
||||
elif "card_faces" in card:
|
||||
for idx, face in enumerate(card["card_faces"]):
|
||||
face_uris = face.get("image_uris", {})
|
||||
url = face_uris.get(image_size)
|
||||
if url:
|
||||
side = "front" if idx == 0 else "back"
|
||||
dest = str(OUTPUT_DIR / f"{scryfall_id}_{side}.jpg")
|
||||
if not resume or not Path(dest).exists():
|
||||
jobs.append((url, dest))
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
def download_image(url: str, dest: str) -> tuple[bool, str]:
|
||||
"""Download a single image. Returns (success, dest)."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
with open(dest, "wb") as f:
|
||||
f.write(resp.read())
|
||||
return True, dest
|
||||
except Exception as exc:
|
||||
log.warning("Failed to download %s -> %s: %s", url, dest, exc)
|
||||
return False, dest
|
||||
|
||||
|
||||
def run_downloads(jobs: list[tuple[str, str]], workers: int) -> tuple[int, int]:
|
||||
"""Download all jobs concurrently. Returns (success_count, fail_count)."""
|
||||
success = 0
|
||||
fail = 0
|
||||
|
||||
iterator = tqdm(total=len(jobs), unit="img", desc="Downloading") if tqdm else None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = {pool.submit(download_image, url, dest): dest for url, dest in jobs}
|
||||
for future in as_completed(futures):
|
||||
ok, _ = future.result()
|
||||
if ok:
|
||||
success += 1
|
||||
else:
|
||||
fail += 1
|
||||
if iterator:
|
||||
iterator.update(1)
|
||||
|
||||
if iterator:
|
||||
iterator.close()
|
||||
|
||||
return success, fail
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bulk download Scryfall card images.")
|
||||
parser.add_argument("--image-size", default=DEFAULT_IMAGE_SIZE,
|
||||
choices=["small", "normal", "large", "png", "art_crop", "border_crop"],
|
||||
help="Image size to download (default: normal = 488x680px)")
|
||||
parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS,
|
||||
help="Concurrent download threads (default: 6)")
|
||||
parser.add_argument("--resume", action="store_true",
|
||||
help="Skip images that already exist on disk")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s"
|
||||
)
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log.info("Output directory: %s", OUTPUT_DIR)
|
||||
|
||||
download_url = fetch_bulk_download_url(args.image_size)
|
||||
cards = load_bulk_data(download_url)
|
||||
jobs = get_image_jobs(cards, args.image_size, args.resume)
|
||||
|
||||
log.info("Jobs to download: %d (resume=%s)", len(jobs), args.resume)
|
||||
if not jobs:
|
||||
log.info("Nothing to do. All images already present.")
|
||||
return
|
||||
|
||||
success, fail = run_downloads(jobs, args.workers)
|
||||
log.info("Done. Success: %d Failed: %d Total: %d", success, fail, len(jobs))
|
||||
if fail > 0:
|
||||
log.warning("%d images failed — re-run with --resume to retry.", fail)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,75 +0,0 @@
|
||||
"""How often does a decision model (Laya / Kev) pick the same action as the LLM did, on game states the app has already recorded?
|
||||
|
||||
DECISION_URL=http://laya:8080 python scripts/farmhouse_decision_eval.py [decisions_dir] [model ...]
|
||||
|
||||
Reads the brain's own decision logs (logs/decisions/*.jsonl: the snapshot and the LLM's answer), asks the decision model the same
|
||||
question for each, and prints agreement overall, at several confidence levels, per action, the "always the most common action"
|
||||
baseline, and the time per answer. The LLM is the teacher here, not the truth: this measures how well a fast model can stand in
|
||||
for the slow one, not how well either plays.
|
||||
"""
|
||||
import collections
|
||||
import glob
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
spec = importlib.util.spec_from_file_location("farmhouse_decide", os.path.join(HERE, "..", "src", "commander_ai_lab", "sim", "farmhouse_decide.py"))
|
||||
D = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(D)
|
||||
|
||||
|
||||
def load(dirpath):
|
||||
rows = []
|
||||
for f in sorted(glob.glob(os.path.join(dirpath, "*.jsonl"))):
|
||||
for line in open(f, encoding="utf-8"):
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if (r.get("decision") or {}).get("source") == "llm" and r.get("game_state") and r["decision"].get("action") in D.ACTION_HELP:
|
||||
rows.append(r)
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
dirpath = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "logs", "decisions")
|
||||
models = sys.argv[2:] or [os.environ.get("DECISION_MODEL", "")]
|
||||
rows = load(dirpath)
|
||||
if not rows:
|
||||
print("no recorded LLM decisions in", dirpath)
|
||||
return 1
|
||||
truth = [r["decision"]["action"] for r in rows]
|
||||
common, n_common = collections.Counter(truth).most_common(1)[0]
|
||||
print("%d recorded decisions; LLM action mix: %s" % (len(rows), dict(collections.Counter(truth))))
|
||||
print("baseline 'always %s': %.1f%%\n" % (common, 100.0 * n_common / len(rows)))
|
||||
for model in models:
|
||||
os.environ["DECISION_MODEL"] = model
|
||||
D.STATS.update({"asked": 0, "agreed": 0, "answered_conf": 0, "errors": 0, "ms_total": 0.0, "by_action": {}})
|
||||
got = []
|
||||
for r in rows:
|
||||
a = D.ask(r["game_state"])
|
||||
got.append(a)
|
||||
D.record(a, r["decision"]["action"])
|
||||
ok = [(g, t) for g, t in zip(got, truth) if g]
|
||||
print("== model %r: answered %d of %d, %.0f ms each" % (model or "auto", len(ok), len(rows), D.stats()["avg_ms"] or 0))
|
||||
if not ok:
|
||||
continue
|
||||
print(" agrees with the LLM on %.1f%% (baseline %.1f%%)" % (100.0 * sum(g["choice"] == t for g, t in ok) / len(ok), 100.0 * n_common / len(rows)))
|
||||
for lo in (0.5, 0.7, 0.85):
|
||||
sel = [(g, t) for g, t in ok if g["confidence"] >= lo]
|
||||
if sel:
|
||||
print(" when it is >= %.2f sure (%d answers, %.0f%%): agrees %.1f%%" % (
|
||||
lo, len(sel), 100.0 * len(sel) / len(ok), 100.0 * sum(g["choice"] == t for g, t in sel) / len(sel)))
|
||||
by = collections.defaultdict(lambda: [0, 0])
|
||||
for g, t in ok:
|
||||
by[t][0] += 1
|
||||
by[t][1] += g["choice"] == t
|
||||
print(" per LLM action:", {k: "%d/%d" % (v[1], v[0]) for k, v in sorted(by.items())})
|
||||
print(" it picked:", dict(collections.Counter(g["choice"] for g, t in ok)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Turn the laya-shadow-v1 capture log into fine-tuning data for Laya (or Kev), plus the numbers a fine-tuned model must beat.
|
||||
|
||||
python scripts/farmhouse_laya_dataset.py [--in data/decisions] [--out data/laya-train] [--teacher llm|csie|selected]
|
||||
[--source all|farmhouse|forge] [--holdout 0.2] [--seed 193]
|
||||
|
||||
Reads every `laya-shadow-*.jsonl` (written when DECISION_MODE is shadow or assist: one line per call, with the text Laya read, the
|
||||
offered options, Laya's own zero-shot answer and the other deciders' picks), keeps the lines whose chosen teacher label is one of the
|
||||
offered options, drops exact duplicates, and splits BY GAME so no game appears on both sides (lines with no game id are split by
|
||||
the hash of their text, which is the same guarantee for identical states). Writes:
|
||||
|
||||
train.jsonl, valid.jsonl one /v1/systemone-shaped example per line: state, questions (with the offered options), answer, meta
|
||||
manifest.json counts, teacher, class balance, and the baselines on the VALIDATION side:
|
||||
- always the commonest action
|
||||
- CSIE's own pick vs the teacher (how much a fine-tuned model must add over the free heuristic)
|
||||
- Laya zero-shot vs the teacher (what was captured before any training)
|
||||
|
||||
Which teacher: `llm` (the slow model; the point is to make a fast student of it), `csie` (the deterministic heuristic; cheap and
|
||||
unlimited but a student can at best equal it), `selected` (what Forge executed). The labels are the teacher's choices, not proof they
|
||||
were right: a student trained on the LLM inherits its mistakes. Laya's own fine-tuning notebook/script (github.com/NandhaKishorM/laya,
|
||||
notebooks/) trains on this kind of typed-decision data; adapt the field names there if its loader differs.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_spec = importlib.util.spec_from_file_location("farmhouse_laya", os.path.join(HERE, "..", "src", "commander_ai_lab", "sim", "farmhouse_laya.py"))
|
||||
LAYA = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(LAYA)
|
||||
|
||||
|
||||
def read_records(directory):
|
||||
for path in sorted(glob.glob(os.path.join(directory, "laya-shadow-*.jsonl"))):
|
||||
for line in open(path, encoding="utf-8"):
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if rec.get("schema") == LAYA.CAPTURE_SCHEMA:
|
||||
yield rec
|
||||
|
||||
|
||||
def bucket(rec, seed):
|
||||
key = rec.get("game_id") or rec.get("state_text") or ""
|
||||
return int(hashlib.sha256(("%s|%s" % (seed, key)).encode("utf-8")).hexdigest()[:8], 16) / 0xFFFFFFFF
|
||||
|
||||
|
||||
def build(records, teacher="llm", source="all", holdout=0.2, seed=193):
|
||||
train, valid, seen = [], [], set()
|
||||
for rec in records:
|
||||
if source != "all" and rec.get("source") != source:
|
||||
continue
|
||||
row = LAYA.training_row(rec, teacher)
|
||||
if not row:
|
||||
continue
|
||||
key = (rec["state_text"], row["answer"]["action"], json.dumps(row["questions"]["action"]["criteria"], sort_keys=True))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
(valid if bucket(rec, seed) < holdout else train).append((row, rec))
|
||||
return train, valid
|
||||
|
||||
|
||||
def baselines(pairs, teacher):
|
||||
"""Agreement with the teacher on `pairs` of (row, record): commonest-action, CSIE, and Laya zero-shot."""
|
||||
n = len(pairs)
|
||||
if not n:
|
||||
return {"n": 0}
|
||||
targets = [r["answer"]["action"] for r, _ in pairs]
|
||||
common, k = collections.Counter(t.rsplit(":", 1)[-1] for t in targets).most_common(1)[0]
|
||||
out = {"n": n, "commonest_action": common, "commonest_agreement": round(k / n, 4)}
|
||||
csie = [(rec.get("labels") or {}).get("csie") for _, rec in pairs]
|
||||
if teacher != "csie":
|
||||
have = [(c, t) for c, t in zip(csie, targets) if c]
|
||||
if have:
|
||||
out["csie_vs_teacher"] = {"n": len(have), "agreement": round(sum(c == t for c, t in have) / len(have), 4)}
|
||||
laya = [((rec.get("laya") or {}).get("choice"), t) for (_, rec), t in zip(pairs, targets)]
|
||||
have = [(c, t) for c, t in laya if c]
|
||||
if have:
|
||||
out["laya_zero_shot_vs_teacher"] = {"n": len(have), "agreement": round(sum(c == t for c, t in have) / len(have), 4)}
|
||||
return out
|
||||
|
||||
|
||||
def write(out_dir, train, valid, teacher, source, holdout, seed):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for name, rows in (("train.jsonl", train), ("valid.jsonl", valid)):
|
||||
with open(os.path.join(out_dir, name), "w", encoding="utf-8", newline="\n") as f:
|
||||
for row, _ in rows:
|
||||
f.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
balance = collections.Counter(r["answer"]["action"].rsplit(":", 1)[-1] for r, _ in train)
|
||||
manifest = {"schema": "laya-train-manifest-v1", "teacher": teacher, "source": source, "holdout": holdout, "seed": seed,
|
||||
"train": len(train), "valid": len(valid), "train_class_balance": dict(balance.most_common()),
|
||||
"validation_baselines": baselines(valid, teacher),
|
||||
"note": "Labels are the teacher's choices, not proof they were right. Split by game id (or text hash)."}
|
||||
with open(os.path.join(out_dir, "manifest.json"), "w", encoding="utf-8", newline="\n") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
return manifest
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
p.add_argument("--in", dest="inp", default=os.path.join("data", "decisions"))
|
||||
p.add_argument("--out", default=os.path.join("data", "laya-train"))
|
||||
p.add_argument("--teacher", choices=("llm", "csie", "selected"), default="llm")
|
||||
p.add_argument("--source", choices=("all", "farmhouse", "forge"), default="all")
|
||||
p.add_argument("--holdout", type=float, default=0.2)
|
||||
p.add_argument("--seed", type=int, default=193)
|
||||
a = p.parse_args(argv)
|
||||
train, valid = build(read_records(a.inp), a.teacher, a.source, a.holdout, a.seed)
|
||||
if not train and not valid:
|
||||
print("no usable records in %s for teacher %r (turn on DECISION_MODE=shadow and play some games first)" % (a.inp, a.teacher))
|
||||
return 1
|
||||
m = write(a.out, train, valid, a.teacher, a.source, a.holdout, a.seed)
|
||||
print(json.dumps(m, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user