Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d92387bb8 | ||
|
|
a09eb5b21a | ||
|
|
0efd4847a3 | ||
|
|
946a3706da | ||
|
|
6569bb6f0d | ||
|
|
32ab268f88 | ||
|
|
abb26a679c | ||
|
|
635890deb6 | ||
|
|
34e0fc6b2f | ||
|
|
b779987802 | ||
|
|
fee8ded52d | ||
|
|
54cc5e9b2e | ||
|
|
9b79d18201 | ||
|
|
45ca961064 | ||
|
|
69bdd94ef2 | ||
|
|
8fbebc21f3 | ||
|
|
7cfdecb2c3 | ||
|
|
38f3837f5a | ||
|
|
681888adcd | ||
|
|
88c0d94d39 | ||
|
|
4fdddf332e | ||
|
|
213b9dc32d | ||
|
|
2af1d58104 | ||
|
|
547a84d8c2 | ||
|
|
71b86b74eb | ||
|
|
d00093897a | ||
|
|
0b01f1c467 | ||
|
|
65873555bc | ||
|
|
8995d743af | ||
|
|
14ff543135 | ||
|
|
ea23f97b9c | ||
|
|
a23fdc55a4 | ||
|
|
4cae97ab44 | ||
|
|
d308e8f413 | ||
|
|
44123f7622 | ||
|
|
9eaa41a4ae | ||
|
|
409aba26f4 | ||
|
|
30c3ab5a89 | ||
|
|
b7f9c119cb | ||
|
|
bbb7f7a966 | ||
|
|
ef739e1f49 | ||
|
|
920d7d9787 | ||
|
|
52d51ebd5e | ||
|
|
db3675aa6a | ||
|
|
99044342c2 | ||
|
|
d5fd648139 | ||
|
|
022f89478d | ||
|
|
336f913d4b | ||
|
|
661847a4d8 | ||
|
|
e710fd5e03 | ||
|
|
85af163123 | ||
|
|
f2e87feb4e | ||
|
|
948f8e0f8f | ||
|
|
84d56bf901 | ||
|
|
74d24d606c | ||
|
|
98e1092ebc | ||
|
|
9338ac881b | ||
|
|
c3ff9f8888 | ||
|
|
e4f35a2d8b | ||
|
|
084742a56a | ||
|
|
45a413837b | ||
|
|
018852c92d | ||
|
|
4ca21e5e29 | ||
|
|
28665bf663 | ||
|
|
83d05294c8 | ||
|
|
e8f8766d59 | ||
|
|
a69b273f5f | ||
|
|
e55ac9aebf | ||
|
|
d2b86ef2ed | ||
|
|
081dd66121 | ||
|
|
6d89d5bb08 | ||
|
|
f29a5a31b5 | ||
|
|
370d4cd92f | ||
|
|
e90efe1654 | ||
|
|
bf701397b0 | ||
|
|
7750b43494 | ||
|
|
fa58463cde | ||
|
|
94b1e050d8 | ||
|
|
8bfde0230b |
No files matched your search
+8
-3
@@ -8,15 +8,20 @@ XIMILAR_API_KEY=your-ximilar-key-here
|
||||
|
||||
# ── 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
|
||||
FORGE_JAR_PATH=D:/ForgeCommander/Forge-Repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||
|
||||
# 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
|
||||
FORGE_WORKING_DIR=D:/ForgeCommander/Forge-Repo/forge-gui
|
||||
|
||||
# ── 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
|
||||
FORGE_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
|
||||
+15
@@ -32,9 +32,24 @@ 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 (generated / imported at runtime)
|
||||
precon-decks/
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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,67 +1,212 @@
|
||||
# Commander AI Lab
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## What It Does
|
||||
---
|
||||
|
||||
| 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 |
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
└────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 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 ││
|
||||
│ └──────────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start (Windows)
|
||||
---
|
||||
|
||||
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
|
||||
## 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)
|
||||
|
||||
```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 deps
|
||||
pip install -e .
|
||||
# 3. Install Python dependencies
|
||||
pip install -e ".[rag,dev]"
|
||||
|
||||
# 4. Launch
|
||||
# 4. Pull an Ollama model for the deck builder
|
||||
ollama pull qwen2.5:7b
|
||||
|
||||
# 5. Launch
|
||||
.\start-lab.bat
|
||||
# → open http://localhost:8080
|
||||
# → Web UI: http://localhost:8080
|
||||
# → LAN Client: http://localhost:8080/lan/
|
||||
```
|
||||
|
||||
Or use the automated installer:
|
||||
|
||||
```powershell
|
||||
.\install-commander-ai-lab.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overnight AFK Runner
|
||||
|
||||
Runs batch sims unattended, then updates card weights and trains the neural net:
|
||||
Run batch simulations unattended, then automatically update card weights and train the neural net:
|
||||
|
||||
```powershell
|
||||
python overnight-run.py # default: 11.5h DeepSeek sims
|
||||
@@ -74,9 +219,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 outcomes automatically improve deck recommendations:
|
||||
## Simulation → Deck Builder Feedback Loop
|
||||
|
||||
```
|
||||
Overnight sims → decisions_*.jsonl
|
||||
@@ -90,24 +235,63 @@ Ollama prompts (suggest_cards + enforce_deck_ratios)
|
||||
Better card recommendations
|
||||
```
|
||||
|
||||
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. No-op until real sim data exists.
|
||||
When active, `BuildResult.sources_consulted` includes `"sim_weights"`. The loop is a no-op until real sim data exists.
|
||||
|
||||
## Deck Builder Pipeline
|
||||
---
|
||||
|
||||
The `POST /api/deck-builder/build` endpoint runs 7 steps:
|
||||
## API Reference
|
||||
|
||||
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
|
||||
### Simulation
|
||||
|
||||
## ML Pipeline
|
||||
| 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
|
||||
|
||||
```powershell
|
||||
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
|
||||
# Update card weights from decision logs
|
||||
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
|
||||
|
||||
# Reset weights to defaults
|
||||
@@ -117,7 +301,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
|
||||
|
||||
@@ -129,52 +313,122 @@ 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
|
||||
├── lab_api.py # FastAPI server entry point
|
||||
├── frontend/ # React + TypeScript web UI
|
||||
├── ui/ # Legacy static HTML UI
|
||||
├── 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
|
||||
│
|
||||
├── src/commander_ai_lab/
|
||||
│ ├── 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
|
||||
│ ├── 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/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 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
|
||||
- **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.
|
||||
@@ -0,0 +1,257 @@
|
||||
<#
|
||||
.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") as f:
|
||||
with open(report_path, "r", encoding="utf-8") 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") as f:
|
||||
with open(path, "w", encoding="utf-8") 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") as f:
|
||||
with open(path, "r", encoding="utf-8") 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") as fh:
|
||||
with open(f, "r", encoding="utf-8") 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()
|
||||
llm_status = self.llm.check_connection_sync() # FARMHOUSE_COACH_STATUS_V1 (was an un-awaited coroutine)
|
||||
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", "gpt-oss:20b")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-4-20250514")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
|
||||
|
||||
+20
-1
@@ -74,7 +74,7 @@ class LLMClient:
|
||||
try:
|
||||
status = await self.check_connection()
|
||||
if status.get("connected") and status.get("active_model"):
|
||||
self._resolved_model = status["active_model"]
|
||||
self._resolved_model = self.model if self.model in (status.get("models") or []) else status["active_model"] # FARMHOUSE_MODEL_PIN_V1
|
||||
logger.info("Resolved Ollama model: %s", self._resolved_model)
|
||||
return self._resolved_model
|
||||
except Exception:
|
||||
@@ -103,6 +103,14 @@ 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 ──────────────────────────────────────
|
||||
@@ -260,6 +268,17 @@ 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,6 +546,21 @@ 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,67 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/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 "$@"
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,96 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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: []
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 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}
|
||||
@@ -0,0 +1,26 @@
|
||||
[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 }
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,237 @@
|
||||
//! 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();
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,272 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,42 @@
|
||||
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')
|
||||
-634
@@ -1,634 +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>
|
||||
</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>
|
||||
|
||||
<!-- CENTER EMBLEM -->
|
||||
<div class="center-emblem">
|
||||
<svg viewBox="0 0 100 100" fill="none">
|
||||
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
|
||||
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
|
||||
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
|
||||
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
|
||||
</svg>
|
||||
</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>
|
||||
<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 class="poison-badge" id="poison-0" style="display:none;" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
|
||||
|
||||
<!-- 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="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 class="poison-badge" id="poison-1" style="display:none;" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
|
||||
|
||||
<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 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 class="poison-badge" id="poison-2" style="display:none;" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
|
||||
</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 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 class="poison-badge" id="poison-3" style="display:none;" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.battlefield -->
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- MANA POOL DISPLAY (floating, bottom center) -->
|
||||
<div id="mana-pool-display" style="display:none;">
|
||||
<span class="mana-pool-label">MANA:</span>
|
||||
<span class="mana-empty">Empty</span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</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="clearAllManaPools()">Clear All Mana</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
|
||||
==================================================== -->
|
||||
<script src="precons.js?v=20260403_0300"></script>
|
||||
<script src="engine.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)
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+54
-17
@@ -38,6 +38,7 @@ 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
|
||||
@@ -50,6 +51,9 @@ _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) ────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
@@ -59,6 +63,7 @@ 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:
|
||||
@@ -77,13 +82,12 @@ 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"):
|
||||
if info.get("stale") and os.environ.get("RAG_AUTOBUILD", "0") == "1": # FARMHOUSE_RAG_MONITOR_V1
|
||||
log.info("RAG staleness monitor: %s — triggering rebuild.", info["reason"])
|
||||
loop.run_in_executor(None, lambda: build_index(force=True))
|
||||
else:
|
||||
@@ -92,20 +96,17 @@ async def _lifespan(application: FastAPI):
|
||||
log.warning("RAG staleness monitor failed: %s", exc)
|
||||
asyncio.ensure_future(_rag_staleness_monitor())
|
||||
|
||||
# Policy routes: register if ML policy model is available
|
||||
# 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.
|
||||
try:
|
||||
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)")
|
||||
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")
|
||||
except Exception as e:
|
||||
log.warning("Policy route registration failed (non-fatal): %s", e)
|
||||
log.warning("Policy model load failed (non-fatal): %s", e)
|
||||
|
||||
yield # ← server is running
|
||||
|
||||
@@ -129,6 +130,7 @@ 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,6 +140,23 @@ 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)
|
||||
|
||||
|
||||
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
|
||||
# NOTE: all @app.get routes MUST be registered before app.mount() calls.
|
||||
@@ -149,11 +168,26 @@ 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 routes) ──────────────────────────────────────────
|
||||
# ── Static UI (MUST come after all @app.get / include_router calls) ──────────────────────────
|
||||
from pathlib import Path as _Path
|
||||
_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():
|
||||
@@ -225,7 +259,9 @@ 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",
|
||||
project_root.parent / "Forge-Repo" / "forge-gui",
|
||||
project_root.parent / "forge-repo", # legacy fallback
|
||||
project_root.parent / "forge-repo" / "forge-gui",
|
||||
project_root.parent / "forge",
|
||||
project_root / "forge-gui-desktop",
|
||||
@@ -251,7 +287,8 @@ 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",
|
||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target", # legacy fallback
|
||||
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
||||
]
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
|
||||
|
||||
+52
-20
@@ -10,9 +10,10 @@ const AI_BRIDGE_VERSION = '1.0.0';
|
||||
// ============================================================
|
||||
|
||||
const AI_BRIDGE_CONFIG = {
|
||||
// Backend URL — defaults to localhost:8080, configurable via settings
|
||||
backendUrl: 'http://localhost:8080',
|
||||
wsUrl: 'ws://localhost:8080',
|
||||
// Backend URL — empty string = same origin (recommended when served from backend)
|
||||
// Falls back to localhost:8080 if served from a different origin
|
||||
backendUrl: '',
|
||||
wsUrl: '',
|
||||
|
||||
// Timeouts (ms)
|
||||
healthCheckTimeout: 3000,
|
||||
@@ -39,6 +40,16 @@ const AI_BRIDGE_CONFIG = {
|
||||
class BackendConnector {
|
||||
constructor(config = {}) {
|
||||
this.config = { ...AI_BRIDGE_CONFIG, ...config };
|
||||
|
||||
// Derive URLs from current page origin when empty (same-origin mode)
|
||||
if (!this.config.backendUrl) {
|
||||
this.config.backendUrl = window.location.origin; // e.g. http://localhost:8080
|
||||
}
|
||||
if (!this.config.wsUrl) {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this.config.wsUrl = `${proto}//${window.location.host}`;
|
||||
}
|
||||
|
||||
this._connected = false;
|
||||
this._modelLoaded = false;
|
||||
this._ws = null;
|
||||
@@ -355,7 +366,11 @@ class GameStateBridge {
|
||||
seat_type: p.isAI
|
||||
? this._mapAiProfile(gameState.aiDifficulty)
|
||||
: 'Human',
|
||||
deck_name: p.commanderCardName || '',
|
||||
deck_name: p.deckFileName || (
|
||||
p.commanderCardName
|
||||
? p.commanderCardName.replace(/[^a-zA-Z0-9]/g, '_') + '.dck'
|
||||
: ''
|
||||
),
|
||||
player_name: p.name,
|
||||
}));
|
||||
|
||||
@@ -383,11 +398,13 @@ class GameStateBridge {
|
||||
*/
|
||||
_mapAiProfile(difficulty) {
|
||||
const map = {
|
||||
easy: 'AI',
|
||||
normal: 'AI Aggro',
|
||||
hard: 'AI Control',
|
||||
easy: 'AI',
|
||||
normal: 'AI Midrange',
|
||||
hard: 'AI Control',
|
||||
combo: 'AI Combo',
|
||||
aggro: 'AI Aggro',
|
||||
};
|
||||
return map[difficulty] || 'AI';
|
||||
return map[difficulty] || 'AI Midrange';
|
||||
}
|
||||
|
||||
// ── Game State Serialization ──────────────────────────────
|
||||
@@ -440,7 +457,10 @@ class GameStateBridge {
|
||||
// Zone contents — card names
|
||||
hand: p.zones.hand.map(c => c.name || 'Unknown'),
|
||||
hand_count: p.zones.hand.length,
|
||||
battlefield: bfCards.map(c => ({
|
||||
// Flat string list for the state encoder
|
||||
battlefield: bfCards.map(c => c.name || 'Unknown'),
|
||||
// Structured data for backend logic that needs detail
|
||||
battlefield_detail: bfCards.map(c => ({
|
||||
name: c.name || 'Unknown',
|
||||
type: c.typeLine || '',
|
||||
power: c.power || '0',
|
||||
@@ -477,7 +497,7 @@ class GameStateBridge {
|
||||
|
||||
return {
|
||||
game_id: this._sessionGameId || '',
|
||||
turn: gameState.turnCounter || 1,
|
||||
turn: gameState.turnNumber || gameState.turnCounter || 1,
|
||||
phase: phaseMap[phaseId] || 'main_1',
|
||||
active_player: playerIdx,
|
||||
players: players,
|
||||
@@ -494,9 +514,9 @@ class GameStateBridge {
|
||||
* Get all battlefield cards for a player.
|
||||
*/
|
||||
_getBattlefieldCards(player, gameState) {
|
||||
if (!gameState || !gameState.battlefield) return [];
|
||||
return (gameState.battlefield || []).filter(
|
||||
c => c.controller === player.id || c.owner === player.id
|
||||
if (!gameState || !gameState.battlefieldCards) return [];
|
||||
return gameState.battlefieldCards.filter(
|
||||
c => c.ownerIndex === player.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -696,7 +716,7 @@ class MacroActionMapper {
|
||||
if (castable.length === 0) return false;
|
||||
castable.sort((a, b) => (MacroActionMapper._cmc(b) - MacroActionMapper._cmc(a)));
|
||||
for (const card of castable) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card)) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
||||
const idx = hand.indexOf(card);
|
||||
if (idx !== -1) {
|
||||
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
||||
@@ -717,7 +737,7 @@ class MacroActionMapper {
|
||||
});
|
||||
|
||||
for (const card of creatures) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card)) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
||||
const idx = hand.indexOf(card);
|
||||
if (idx !== -1) {
|
||||
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
||||
@@ -741,7 +761,7 @@ class MacroActionMapper {
|
||||
if (removal.length === 0) return false;
|
||||
|
||||
for (const card of removal) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card)) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
||||
const idx = hand.indexOf(card);
|
||||
if (idx !== -1) {
|
||||
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
||||
@@ -770,7 +790,7 @@ class MacroActionMapper {
|
||||
);
|
||||
|
||||
for (const card of drawSpells) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card)) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
||||
const idx = hand.indexOf(card);
|
||||
if (idx !== -1) {
|
||||
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
||||
@@ -815,7 +835,7 @@ class MacroActionMapper {
|
||||
);
|
||||
|
||||
for (const card of rampSpells) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card)) {
|
||||
if (MacroActionMapper._canAfford(playerIdx, card, gameState)) {
|
||||
const idx = hand.indexOf(card);
|
||||
if (idx !== -1) {
|
||||
if (!card.imageUrl) card.imageUrl = generateAiCardSVG(card);
|
||||
@@ -873,12 +893,24 @@ class MacroActionMapper {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static _canAfford(playerIdx, card) {
|
||||
static _canAfford(playerIdx, card, gameState) {
|
||||
if (!gameState) return false;
|
||||
const cmc = MacroActionMapper._cmc(card);
|
||||
if (cmc === 0) return true; // Free spells always castable
|
||||
|
||||
// Prefer actual mana pool if populated
|
||||
const player = gameState.players[playerIdx];
|
||||
const pool = player?.manaPool;
|
||||
if (pool) {
|
||||
const total = Object.values(pool).reduce((s, v) => s + (v || 0), 0);
|
||||
if (total > 0) return total >= cmc;
|
||||
}
|
||||
|
||||
// Fallback: count untapped lands
|
||||
const available = typeof aiCountUntappedLands === 'function'
|
||||
? aiCountUntappedLands(playerIdx)
|
||||
: 0;
|
||||
return available >= MacroActionMapper._cmc(card);
|
||||
return available >= cmc;
|
||||
}
|
||||
|
||||
static _creatureScore(card) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,34 @@
|
||||
<!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
@@ -0,0 +1,495 @@
|
||||
<!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
@@ -0,0 +1,245 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,795 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
+232
-109
@@ -2088,7 +2088,7 @@ function autoResolveTriggerEffect(trigger) {
|
||||
|
||||
// Helper: find the card on battlefield for counter placement
|
||||
function findCardOnBattlefield(cardName, ownerIdx) {
|
||||
var bf = (gameState.players[ownerIdx] || {}).battlefield || [];
|
||||
var bf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === ownerIdx; });
|
||||
for (var i = 0; i < bf.length; i++) {
|
||||
if (bf[i].name === cardName) return bf[i];
|
||||
}
|
||||
@@ -2233,7 +2233,7 @@ function autoResolveTriggerEffect(trigger) {
|
||||
var opps4 = getOpponents(oi);
|
||||
var placed = false;
|
||||
for (var cmi = 0; cmi < opps4.length && !placed; cmi++) {
|
||||
var cmBf = (gameState.players[opps4[cmi]] || {}).battlefield || [];
|
||||
var cmBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === opps4[cmi]; });
|
||||
for (var cmj = 0; cmj < cmBf.length && !placed; cmj++) {
|
||||
if (cmBf[cmj].type_line && cmBf[cmj].type_line.toLowerCase().indexOf('creature') !== -1) {
|
||||
cmBf[cmj].counters = cmBf[cmj].counters || {};
|
||||
@@ -2250,7 +2250,7 @@ function autoResolveTriggerEffect(trigger) {
|
||||
|
||||
case 'counter_all': {
|
||||
// +1/+1 counter on each creature you control
|
||||
var caBf = (gameState.players[oi] || {}).battlefield || [];
|
||||
var caBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
|
||||
var caCount = 0;
|
||||
for (var cai = 0; cai < caBf.length; cai++) {
|
||||
if (caBf[cai].type_line && caBf[cai].type_line.toLowerCase().indexOf('creature') !== -1) {
|
||||
@@ -2362,7 +2362,7 @@ function autoResolveTriggerEffect(trigger) {
|
||||
}
|
||||
|
||||
case 'untap_all': {
|
||||
var uaBf = (gameState.players[oi] || {}).battlefield || [];
|
||||
var uaBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; });
|
||||
var uaCount = 0;
|
||||
for (var uai = 0; uai < uaBf.length; uai++) {
|
||||
if (uaBf[uai].tapped) {
|
||||
@@ -3247,19 +3247,15 @@ function addCardToBattlefield(name, imageUrl, typeLine, ownerIndex, x, y, oracle
|
||||
} else {
|
||||
// Place cards by type in designated battlefield zones
|
||||
var zone = getCardTypeZone(typeLine || '');
|
||||
var cardW = 110, cardH = 150;
|
||||
var cardW = 63, cardH = 84;
|
||||
// Zone regions (percentage of section area)
|
||||
// Creatures: top center (30%-70% x, 5%-45% y)
|
||||
// Artifacts/Enchantments: top right (70%-95% x, 5%-45% y)
|
||||
// Lands: bottom left (5%-35% x, 55%-90% y)
|
||||
// Instants/Sorceries: bottom center (35%-65% x, 55%-90% y)
|
||||
// Other: center (30%-70% x, 25%-75% y)
|
||||
// Offset from edges to avoid overlapping the overlaid zone-row/commander-row
|
||||
var zones = {
|
||||
creature: { x1: 0.25, x2: 0.65, y1: 0.05, y2: 0.42 },
|
||||
artifact: { x1: 0.68, x2: 0.95, y1: 0.05, y2: 0.42 },
|
||||
land: { x1: 0.03, x2: 0.32, y1: 0.55, y2: 0.90 },
|
||||
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.90 },
|
||||
other: { x1: 0.25, x2: 0.70, y1: 0.25, y2: 0.75 }
|
||||
creature: { x1: 0.25, x2: 0.70, y1: 0.20, y2: 0.55 },
|
||||
artifact: { x1: 0.70, x2: 0.95, y1: 0.20, y2: 0.55 },
|
||||
land: { x1: 0.03, x2: 0.35, y1: 0.55, y2: 0.85 },
|
||||
spell: { x1: 0.35, x2: 0.65, y1: 0.55, y2: 0.85 },
|
||||
other: { x1: 0.25, x2: 0.70, y1: 0.30, y2: 0.70 }
|
||||
};
|
||||
var z = zones[zone] || zones.other;
|
||||
var areaW = rect.width;
|
||||
@@ -5290,8 +5286,7 @@ function setLife(playerId, newLife) {
|
||||
eliminatePlayer(playerId, 'life total reached 0');
|
||||
}
|
||||
checkStateBasedActions();
|
||||
// Sync enhanced player panels
|
||||
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
|
||||
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -5326,8 +5321,7 @@ function adjustCommanderDamage(targetPlayerId, sourcePlayerId, amount) {
|
||||
panelContainer.replaceWith(newPanel);
|
||||
}
|
||||
checkStateBasedActions();
|
||||
// Sync enhanced player panels
|
||||
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
|
||||
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -5360,8 +5354,6 @@ function adjustPoison(playerId, amount) {
|
||||
panelContainer.replaceWith(newPanel);
|
||||
}
|
||||
if (gameState.rulesEngine && gameState.rulesEngine.sbaChecks) checkStateBasedActions();
|
||||
// Sync enhanced player panels
|
||||
if (typeof renderEnhancedPanels === 'function') renderEnhancedPanels();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -8245,6 +8237,7 @@ function drawCard(playerId) {
|
||||
}
|
||||
|
||||
const card = player.zones.library.shift(); // Take from top (index 0)
|
||||
if (typeof _enrichCardFromCache === 'function') _enrichCardFromCache(card);
|
||||
player.zones.hand.push(card);
|
||||
addLogEntry('<strong>' + player.name + '</strong> drew a card (' + player.zones.hand.length + ' in hand)');
|
||||
updatePlayerZones();
|
||||
@@ -10877,16 +10870,20 @@ function parseDeckList(text) {
|
||||
let line = raw.trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Section headers
|
||||
if (/^\/\/\s*/i.test(line) || /^(COMMANDER|COMPANION|SIDEBOARD|MAYBEBOARD|MAINBOARD|DECK|LAND)[:\s]*$/i.test(line)) {
|
||||
const cleaned = line.replace(/^\/\/\s*/, '').replace(/:$/, '').trim().toLowerCase();
|
||||
// Section headers — supports "// Commander", "Commander:", "[Commander]", "[Main]", "[metadata]"
|
||||
if (/^\/\/\s*/i.test(line) || /^\[?(COMMANDER|COMPANION|SIDEBOARD|MAYBEBOARD|MAINBOARD|MAIN|DECK|LAND|METADATA)\]?[:\s]*$/i.test(line)) {
|
||||
const cleaned = line.replace(/^\/\/\s*/, '').replace(/[\[\]:]/g, '').trim().toLowerCase();
|
||||
if (/commander/i.test(cleaned)) currentSection = 'commander';
|
||||
else if (/companion/i.test(cleaned)) currentSection = 'companion';
|
||||
else if (/sideboard/i.test(cleaned)) currentSection = 'sideboard';
|
||||
else if (/metadata/i.test(cleaned)) currentSection = 'metadata';
|
||||
else currentSection = 'mainboard';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip metadata lines like "Name=Elven Empire"
|
||||
if (currentSection === 'metadata') continue;
|
||||
|
||||
// Skip comment lines
|
||||
if (/^#/.test(line) || /^\*/.test(line)) continue;
|
||||
|
||||
@@ -12556,32 +12553,6 @@ function renderDeckSelectionGrid() {
|
||||
'<div style="color:var(--color-text-secondary);font-size:var(--text-sm);">AI Deck (auto-generated)</div>';
|
||||
} else {
|
||||
var idx = i;
|
||||
// Build saved decks options
|
||||
var savedDecks = (typeof getDecks === 'function') ? getDecks() : [];
|
||||
var savedDecksHtml = '';
|
||||
if (savedDecks.length === 0) {
|
||||
savedDecksHtml = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">No saved decks. Build one in Deck Builder first.</div>';
|
||||
} else {
|
||||
savedDecksHtml = '<div class="setup-custom-deck-list" id="setup-custom-deck-list-' + idx + '">';
|
||||
for (var di = 0; di < savedDecks.length; di++) {
|
||||
var sd = savedDecks[di];
|
||||
var cardCount = 0;
|
||||
if (sd.cards) sd.cards.forEach(function(c) { cardCount += (c.quantity || 1); });
|
||||
if (sd.commander) cardCount++;
|
||||
var deckColors = '';
|
||||
if (sd.commander && sd.commander.colorIdentity && sd.commander.colorIdentity.length) {
|
||||
var manaSymbols = { W: '\u2600', U: '\uD83D\uDCA7', B: '\uD83D\uDC80', R: '\uD83D\uDD25', G: '\uD83C\uDF33' };
|
||||
sd.commander.colorIdentity.forEach(function(c) { deckColors += (manaSymbols[c] || c) + ' '; });
|
||||
}
|
||||
savedDecksHtml += '<button class="setup-custom-deck-btn" id="setup-custom-deck-btn-' + idx + '-' + di + '"' +
|
||||
' onclick="selectSetupCustomDeck(' + idx + ',\'' + sd.id.replace(/'/g, "\\'") + '\')">' +
|
||||
'<span class="setup-custom-deck-name">' + (sd.name || 'Untitled').replace(/</g, '<') + '</span>' +
|
||||
'<span class="setup-custom-deck-meta">' + deckColors + cardCount + ' cards</span>' +
|
||||
'</button>';
|
||||
}
|
||||
savedDecksHtml += '</div>';
|
||||
}
|
||||
|
||||
panel.innerHTML =
|
||||
'<div class="deck-select-panel-header">' +
|
||||
'<div class="player-color-dot" style="background:' + color + '"></div>' +
|
||||
@@ -12591,7 +12562,6 @@ function renderDeckSelectionGrid() {
|
||||
'<div class="deck-select-tabs">' +
|
||||
'<button class="deck-select-tab active" id="setup-deck-tab-precon-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'precon\')">Precon</button>' +
|
||||
'<button class="deck-select-tab" id="setup-deck-tab-mydecks-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'mydecks\')">My Decks</button>' +
|
||||
'<button class="deck-select-tab" id="setup-deck-tab-import-' + idx + '" onclick="switchSetupDeckTab(' + idx + ',\'import\')">Import</button>' +
|
||||
'</div>' +
|
||||
'<div class="deck-select-tab-content active" id="setup-deck-content-precon-' + idx + '">' +
|
||||
'<select class="deck-select-precon-select" id="setup-deck-precon-select-' + idx + '" onchange="selectSetupPrecon(' + idx + ')">' +
|
||||
@@ -12599,20 +12569,95 @@ function renderDeckSelectionGrid() {
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="deck-select-tab-content" id="setup-deck-content-mydecks-' + idx + '">' +
|
||||
savedDecksHtml +
|
||||
'</div>' +
|
||||
'<div class="deck-select-tab-content" id="setup-deck-content-import-' + idx + '">' +
|
||||
'<textarea class="deck-select-import-text" id="setup-deck-import-text-' + idx + '" placeholder="Paste deck list here... // Commander 1 Atraxa, Praetors\' Voice // Deck 1 Sol Ring ..."></textarea>' +
|
||||
'<button class="deck-select-import-btn" onclick="importSetupDeck(' + idx + ')">\uD83D\uDCE5 Validate & Use This List</button>' +
|
||||
'<div class="setup-custom-deck-list" id="setup-custom-deck-list-' + idx + '">' +
|
||||
'<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">Loading user decks...</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
grid.appendChild(panel);
|
||||
}
|
||||
|
||||
// Fetch user-created decks from the API and populate My Decks tabs
|
||||
_loadUserDecksForSetup();
|
||||
}
|
||||
|
||||
function _loadUserDecksForSetup() {
|
||||
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
|
||||
fetch(apiBase + '/api/lab/decks')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; });
|
||||
for (var i = 0; i < setupPlayerCount; i++) {
|
||||
if (setupAiPlayers.has(i)) continue;
|
||||
var listEl = document.getElementById('setup-custom-deck-list-' + i);
|
||||
if (!listEl) continue;
|
||||
|
||||
if (decks.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">No saved decks. Build one in Deck Builder first.</div>';
|
||||
continue;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
for (var di = 0; di < decks.length; di++) {
|
||||
var d = decks[di];
|
||||
var cardCount = d.totalCards || d.card_count || d.total_cards || 0;
|
||||
var cmdr = d.commander || '';
|
||||
html += '<button class="setup-custom-deck-btn" id="setup-custom-deck-btn-' + i + '-' + di + '"' +
|
||||
' onclick="selectSetupUserDeck(' + i + ',' + d.deck_id + ',\'' + (d.name || 'Untitled').replace(/'/g, "\\'") + '\')">' +
|
||||
'<span class="setup-custom-deck-name">' + (d.name || 'Untitled').replace(/</g, '<') + '</span>' +
|
||||
'<span class="setup-custom-deck-meta">' + (cmdr ? cmdr + ' \u2014 ' : '') + cardCount + ' cards</span>' +
|
||||
'</button>';
|
||||
}
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.warn('[Setup] Could not load user decks:', e);
|
||||
for (var i = 0; i < setupPlayerCount; i++) {
|
||||
if (setupAiPlayers.has(i)) continue;
|
||||
var listEl = document.getElementById('setup-custom-deck-list-' + i);
|
||||
if (listEl) listEl.innerHTML = '<div style="color:var(--color-text-faint);font-size:var(--text-sm);padding:var(--space-3);">Could not load decks. Is the server running?</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function selectSetupUserDeck(playerIdx, deckId, deckName) {
|
||||
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
|
||||
|
||||
// Highlight selected button
|
||||
var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx);
|
||||
if (listEl) {
|
||||
listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); });
|
||||
}
|
||||
// Find clicked button and highlight it
|
||||
var allBtns = listEl ? listEl.querySelectorAll('.setup-custom-deck-btn') : [];
|
||||
allBtns.forEach(function(b) {
|
||||
if (b.textContent.indexOf(deckName) !== -1) b.classList.add('selected');
|
||||
});
|
||||
|
||||
// Export the deck to a .dck file, then store it as a precon-style choice
|
||||
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
|
||||
if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; }
|
||||
|
||||
fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' })
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(result) {
|
||||
if (result.success) {
|
||||
setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' };
|
||||
if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
|
||||
} else {
|
||||
if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; }
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error('[Setup] Export failed:', e);
|
||||
if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; }
|
||||
});
|
||||
}
|
||||
|
||||
function switchSetupDeckTab(playerIdx, tab) {
|
||||
var tabs = ['precon', 'mydecks', 'import'];
|
||||
var tabs = ['precon', 'mydecks'];
|
||||
tabs.forEach(function(t) {
|
||||
var tabBtn = document.getElementById('setup-deck-tab-' + t + '-' + playerIdx);
|
||||
var content = document.getElementById('setup-deck-content-' + t + '-' + playerIdx);
|
||||
@@ -12641,51 +12686,6 @@ function selectSetupPrecon(playerIdx) {
|
||||
if (statusEl) { statusEl.textContent = 'Precon: ' + preconName; statusEl.className = 'deck-select-status loaded'; }
|
||||
}
|
||||
|
||||
function importSetupDeck(playerIdx) {
|
||||
var textarea = document.getElementById('setup-deck-import-text-' + playerIdx);
|
||||
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
|
||||
if (!textarea) return;
|
||||
var text = textarea.value.trim();
|
||||
if (!text) {
|
||||
if (statusEl) { statusEl.textContent = 'Paste a deck list first.'; statusEl.className = 'deck-select-status'; }
|
||||
return;
|
||||
}
|
||||
var parsed = parseDeckList(text);
|
||||
if (!parsed.length) {
|
||||
if (statusEl) { statusEl.textContent = 'Could not parse cards from list.'; statusEl.className = 'deck-select-status'; }
|
||||
return;
|
||||
}
|
||||
var total = parsed.reduce(function(sum, e) { return sum + e.qty; }, 0);
|
||||
setupDeckChoices[playerIdx] = { type: 'import', text: text };
|
||||
if (statusEl) { statusEl.textContent = 'Deck ready (' + total + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
|
||||
}
|
||||
|
||||
function selectSetupCustomDeck(playerIdx, deckId) {
|
||||
var decks = (typeof getDecks === 'function') ? getDecks() : [];
|
||||
var deck = decks.find(function(d) { return d.id === deckId; });
|
||||
if (!deck) return;
|
||||
var statusEl = document.getElementById('setup-deck-status-' + playerIdx);
|
||||
|
||||
// Highlight selected button, unhighlight others
|
||||
var listEl = document.getElementById('setup-custom-deck-list-' + playerIdx);
|
||||
if (listEl) {
|
||||
listEl.querySelectorAll('.setup-custom-deck-btn').forEach(function(b) { b.classList.remove('selected'); });
|
||||
}
|
||||
var btns = document.querySelectorAll('[id^="setup-custom-deck-btn-' + playerIdx + '-"]');
|
||||
btns.forEach(function(b) { b.classList.remove('selected'); });
|
||||
// Find the right button by matching deckId in onclick
|
||||
btns.forEach(function(b) {
|
||||
if (b.getAttribute('onclick') && b.getAttribute('onclick').indexOf(deckId) !== -1) {
|
||||
b.classList.add('selected');
|
||||
}
|
||||
});
|
||||
|
||||
setupDeckChoices[playerIdx] = { type: 'custom', deckId: deckId };
|
||||
var cardCount = 0;
|
||||
if (deck.cards) deck.cards.forEach(function(c) { cardCount += (c.quantity || 1); });
|
||||
if (deck.commander) cardCount++;
|
||||
if (statusEl) { statusEl.textContent = deck.name + ' (' + cardCount + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
|
||||
}
|
||||
|
||||
async function loadCustomDeckForPlayer(playerIdx, deckId) {
|
||||
var decks = (typeof getDecks === 'function') ? getDecks() : [];
|
||||
@@ -12719,7 +12719,31 @@ async function loadPreconForPlayer(playerIdx, preconId) {
|
||||
var player = gameState.players[playerIdx];
|
||||
if (!player) return;
|
||||
|
||||
var parsedCards = parseDeckList(precon.decklist);
|
||||
// Try to get decklist text: embedded first, then fetch .dck from server
|
||||
var decklistText = precon.decklist || '';
|
||||
if (!decklistText && precon.fileName) {
|
||||
// 1. Try the API endpoint (returns JSON with decklist field)
|
||||
try {
|
||||
var apiResp = await fetch('/api/lab/precons/deck?fileName=' + encodeURIComponent(precon.fileName));
|
||||
if (apiResp.ok) {
|
||||
var deckData = await apiResp.json();
|
||||
if (deckData && deckData.decklist) decklistText = deckData.decklist;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[loadPreconForPlayer] API deck fetch failed:', e);
|
||||
}
|
||||
// 2. Fallback: fetch .dck file directly from static mount
|
||||
if (!decklistText) {
|
||||
try {
|
||||
var staticResp = await fetch('/precon-decks/' + encodeURIComponent(precon.fileName));
|
||||
if (staticResp.ok) decklistText = await staticResp.text();
|
||||
} catch (e2) {
|
||||
console.warn('[loadPreconForPlayer] Static .dck fetch failed:', precon.fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parsedCards = parseDeckList(decklistText);
|
||||
if (!parsedCards.length) return;
|
||||
|
||||
var allCardNames = [];
|
||||
@@ -12755,6 +12779,9 @@ async function loadPreconForPlayer(playerIdx, preconId) {
|
||||
if (commandCards.length > 0) {
|
||||
player.commanderCardName = commandCards[0].name || '';
|
||||
}
|
||||
if (precon.fileName) {
|
||||
player.deckFileName = precon.fileName;
|
||||
}
|
||||
|
||||
// Shuffle library
|
||||
for (var si = libraryCards.length - 1; si > 0; si--) {
|
||||
@@ -12773,6 +12800,28 @@ async function loadPreconForPlayer(playerIdx, preconId) {
|
||||
addLogEntry('\uD83D\uDCCB <strong>' + player.name + '</strong> loaded precon "' + precon.name + '" (' + (libraryCards.length + commandCards.length) + ' cards)');
|
||||
}
|
||||
|
||||
async function loadUserDeckForPlayer(playerIdx, deckName) {
|
||||
var player = gameState.players[playerIdx];
|
||||
if (!player || !deckName) return;
|
||||
|
||||
// Fetch the .dck file content from user-decks/ via static mount or API
|
||||
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
|
||||
var deckText = '';
|
||||
try {
|
||||
var resp = await fetch(apiBase + '/user-decks/' + encodeURIComponent(deckName) + '.dck');
|
||||
if (resp.ok) deckText = await resp.text();
|
||||
} catch (e) {
|
||||
console.warn('[loadUserDeckForPlayer] Could not fetch .dck file:', e);
|
||||
}
|
||||
if (!deckText) {
|
||||
addLogEntry('\u26A0 Could not load user deck "' + deckName + '"');
|
||||
return;
|
||||
}
|
||||
|
||||
await importDeckForPlayer(playerIdx, deckText);
|
||||
player.deckFileName = deckName + '.dck';
|
||||
}
|
||||
|
||||
async function importDeckForPlayer(playerIdx, deckText) {
|
||||
var player = gameState.players[playerIdx];
|
||||
if (!player || !deckText.trim()) return;
|
||||
@@ -12872,8 +12921,8 @@ async function startGameWithDecks() {
|
||||
await loadPreconForPlayer(hd.idx, hd.choice.preconId);
|
||||
} else if (hd.choice.type === 'custom') {
|
||||
await loadCustomDeckForPlayer(hd.idx, hd.choice.deckId);
|
||||
} else if (hd.choice.type === 'import') {
|
||||
await importDeckForPlayer(hd.idx, hd.choice.text);
|
||||
} else if (hd.choice.type === 'user-deck') {
|
||||
await loadUserDeckForPlayer(hd.idx, hd.choice.deckName);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -12996,6 +13045,8 @@ function startGame() {
|
||||
renderHandArea();
|
||||
renderPhaseTracker();
|
||||
updateTurnDisplay();
|
||||
if (typeof renderCmdDmgInline === 'function') renderCmdDmgInline();
|
||||
if (typeof refreshLeftPanelTriggers === 'function') refreshLeftPanelTriggers();
|
||||
|
||||
// Set default search focus
|
||||
setTimeout(() => {
|
||||
@@ -16201,6 +16252,72 @@ function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) {
|
||||
return assignments;
|
||||
}
|
||||
|
||||
// ---- AI Helper: Declare attacks for an AI player (used by ai-bridge) ----
|
||||
function aiDeclareAttacks(playerIdx) {
|
||||
if (!gameState) return;
|
||||
var player = gameState.players[playerIdx];
|
||||
if (!player || player.eliminated) return;
|
||||
var difficulty = gameState.aiDifficulty || 'easy';
|
||||
|
||||
var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) {
|
||||
return !hasSummoningSickness(c);
|
||||
});
|
||||
if (creatures.length === 0) return;
|
||||
|
||||
var attackerCards = [];
|
||||
|
||||
if (difficulty === 'easy') {
|
||||
attackerCards = creatures.filter(function() { return Math.random() > 0.5; });
|
||||
if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]];
|
||||
} else {
|
||||
var primaryTarget = aiPickAttackTarget(playerIdx);
|
||||
if (primaryTarget === -1) return;
|
||||
|
||||
creatures.forEach(function(c) {
|
||||
var eval_ = aiEvaluateAttack(c, primaryTarget);
|
||||
if (difficulty === 'normal') {
|
||||
if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c);
|
||||
} else {
|
||||
if (eval_.shouldAttack) attackerCards.push(c);
|
||||
else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c);
|
||||
}
|
||||
});
|
||||
|
||||
if (difficulty === 'hard' && attackerCards.length > 2) {
|
||||
var sortedByToughness = attackerCards.slice().sort(function(a, b) {
|
||||
return getCreatureToughness(b) - getCreatureToughness(a);
|
||||
});
|
||||
var holdBack = sortedByToughness.find(function(c) {
|
||||
return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3;
|
||||
});
|
||||
if (holdBack) {
|
||||
attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (attackerCards.length === 0) return;
|
||||
|
||||
var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty);
|
||||
if (assignments.length === 0) return;
|
||||
|
||||
gameState.combat.attackers = assignments.map(function(a) {
|
||||
return { cardId: a.card.id, targetPlayerId: a.targetIdx };
|
||||
});
|
||||
|
||||
attackerCards.forEach(function(c) {
|
||||
if (!hasKeyword(c, 'vigilance')) {
|
||||
c.tapped = true;
|
||||
var el = document.getElementById('bc-' + c.id);
|
||||
if (el) el.classList.add('tapped');
|
||||
}
|
||||
var el2 = document.getElementById('bc-' + c.id);
|
||||
if (el2) el2.classList.add('attacking');
|
||||
});
|
||||
|
||||
fireBattlefieldTriggers('attack');
|
||||
}
|
||||
|
||||
// ---- AI Helper: Check if AI has a counterspell and should use it ----
|
||||
function aiCheckForCounterspell(playerIdx) {
|
||||
if (!gameState) return null;
|
||||
@@ -16569,7 +16686,7 @@ function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) {
|
||||
var postDelay = 600;
|
||||
|
||||
// MAIN PHASE 2 (same logic as inline version)
|
||||
if (difficulty !== 'easy') {
|
||||
{
|
||||
postActions.push({ delay: postDelay, fn: function() {
|
||||
var bluffReserve = 0;
|
||||
var bluffCardName = '';
|
||||
@@ -17181,12 +17298,12 @@ function _runLocalAiTurn() {
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MAIN PHASE 2: Post-combat spell casting (normal & hard)
|
||||
// MAIN PHASE 2: Post-combat spell casting
|
||||
// Hard: play held-back removal + instants + any remaining affordable spells
|
||||
// Normal: try to play any remaining affordable spells
|
||||
// Normal/Easy: try to play any remaining affordable spells
|
||||
// BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers
|
||||
// ========================================
|
||||
if (difficulty !== 'easy') {
|
||||
{
|
||||
actions.push({ delay: delay, fn: function() {
|
||||
if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this
|
||||
var bluffReserve = 0;
|
||||
@@ -25610,3 +25727,9 @@ function _safeRender(fn, ...args) {
|
||||
try { window[fn](...args); } catch(e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose functions needed by ai-bridge.js
|
||||
window.aiTryCastCommander = aiTryCastCommander;
|
||||
window.aiDeclareAttacks = aiDeclareAttacks;
|
||||
window.playCardFromHand = playCardFromHand;
|
||||
window.aiCountUntappedLands = aiCountUntappedLands;
|
||||
@@ -0,0 +1,955 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
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);
|
||||
}
|
||||
+334
-33
@@ -51,6 +51,8 @@
|
||||
<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 -->
|
||||
@@ -67,15 +69,6 @@
|
||||
<div class="corner-accent bl"></div>
|
||||
<div class="corner-accent br"></div>
|
||||
|
||||
<!-- CENTER EMBLEM -->
|
||||
<div class="center-emblem">
|
||||
<svg viewBox="0 0 100 100" fill="none">
|
||||
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
|
||||
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
|
||||
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
|
||||
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ================================================
|
||||
4-QUADRANT BATTLEFIELD
|
||||
@@ -85,12 +78,6 @@
|
||||
<!-- 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>
|
||||
<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 class="poison-badge" id="poison-0" style="display:none;" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
|
||||
|
||||
<!-- Zone Row: Library, Exile, Graveyard, Hand -->
|
||||
<div class="zone-row" id="zone-row-1">
|
||||
@@ -139,12 +126,6 @@
|
||||
<!-- 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="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 class="poison-badge" id="poison-1" style="display:none;" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
|
||||
|
||||
<div class="zone-row" id="zone-row-2">
|
||||
<div class="zone library" id="lib-zone-2" data-player="2" data-zone="library"
|
||||
@@ -229,12 +210,6 @@
|
||||
</div>
|
||||
|
||||
<span class="player-label" id="player-label-3">Player 3</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 class="poison-badge" id="poison-2" style="display:none;" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 4: Bottom-Right (Gold) -->
|
||||
@@ -279,16 +254,89 @@
|
||||
</div>
|
||||
|
||||
<span class="player-label" id="player-label-4">Player 4</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 class="poison-badge" id="poison-3" style="display:none;" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
|
||||
</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>
|
||||
@@ -321,6 +369,24 @@
|
||||
<!-- 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 -->
|
||||
|
||||
<!-- ====================================================
|
||||
@@ -524,10 +590,18 @@
|
||||
|
||||
<!-- ====================================================
|
||||
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>
|
||||
|
||||
@@ -634,10 +708,237 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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>
|
||||
@@ -228,6 +228,11 @@ function removeEmblem(playerId, idx) {
|
||||
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.
|
||||
*/
|
||||
@@ -236,7 +241,7 @@ function renderEnhancedPanels() {
|
||||
|
||||
for (var i = 0; i < gameState.players.length; i++) {
|
||||
var player = gameState.players[i];
|
||||
var quadrant = i + 1;
|
||||
var quadrant = PLAYER_INDEX_TO_QUADRANT[i] || (i + 1);
|
||||
var areaEl = document.getElementById('player-area-' + quadrant);
|
||||
if (!areaEl) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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}
|
||||
];
|
||||
+707
-174
File diff suppressed because it is too large.
Load diff
+292
-74
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* MTG Commander Simulator — ui.js
|
||||
/**
|
||||
* MTG Commander Simulator — ui.js
|
||||
* All UI rendering functions adapted for the 4-quadrant battlefield layout.
|
||||
* This file overrides/extends DOM-touching functions from engine.js.
|
||||
* ============================================================
|
||||
@@ -17,6 +17,12 @@ let undoHistoryVisible = false;
|
||||
let aiTurnActive = false;
|
||||
let floatingPreviewTimeout = null;
|
||||
|
||||
// Hand-to-battlefield drag state
|
||||
let isDraggingFromHand = false;
|
||||
let handDragPlayerIdx = null;
|
||||
let handDragCardIdx = null;
|
||||
let handDragGhost = null;
|
||||
|
||||
// ============================================================
|
||||
// LOG SYSTEM (override engine stub)
|
||||
// ============================================================
|
||||
@@ -83,7 +89,7 @@ function setLife(playerId, newLife) {
|
||||
updateLifeCounterDisplay(playerId);
|
||||
const diff = newLife - oldLife;
|
||||
const sign = diff > 0 ? '+' : '';
|
||||
addLogEntry('<strong>' + player.name + '</strong> life: ' + oldLife + ' → <strong>' + newLife + '</strong> (' + sign + diff + ')');
|
||||
addLogEntry('<strong>' + player.name + '</strong> life: ' + oldLife + ' → <strong>' + newLife + '</strong> (' + sign + diff + ')');
|
||||
if (diff > 0) fireBattlefieldTriggers('life_gain');
|
||||
else if (diff < 0) fireBattlefieldTriggers('life_loss');
|
||||
if (newLife <= 0 && !player.eliminated) eliminatePlayer(playerId, 'life total reached 0');
|
||||
@@ -117,7 +123,7 @@ function updateActivePlayerHighlight() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDER PLAYER PANELS (adapted for 4-quadrant — updates zones/labels)
|
||||
// RENDER PLAYER PANELS (adapted for 4-quadrant — updates zones/labels)
|
||||
// ============================================================
|
||||
function renderPlayerPanels() {
|
||||
if (!gameState) return;
|
||||
@@ -144,23 +150,13 @@ function updateZoneCountsForPlayer(playerIdx) {
|
||||
const libCount = document.getElementById('lib-count-' + pNum);
|
||||
if (libCount) libCount.textContent = player.zones.library.length;
|
||||
|
||||
// Update exile count
|
||||
const exCount = document.getElementById('exile-count-' + pNum);
|
||||
if (exCount) {
|
||||
exCount.textContent = player.zones.exile.length;
|
||||
exCount.style.display = player.zones.exile.length > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update graveyard count
|
||||
const gvCount = document.getElementById('gv-count-' + pNum);
|
||||
if (gvCount) {
|
||||
gvCount.textContent = player.zones.graveyard.length;
|
||||
gvCount.style.display = player.zones.graveyard.length > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update hand count
|
||||
const handCount = document.getElementById('hand-count-' + pNum);
|
||||
if (handCount) handCount.textContent = player.zones.hand.length;
|
||||
|
||||
// Exile + Graveyard: render last card image face-up
|
||||
renderLastCardInZone(playerIdx, 'exile');
|
||||
renderLastCardInZone(playerIdx, 'graveyard');
|
||||
}
|
||||
|
||||
function updatePlayerZones() {
|
||||
@@ -211,6 +207,50 @@ function renderCommanderZone(playerIdx) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EXILE / GRAVEYARD — Show last card image face-up
|
||||
// ============================================================
|
||||
function renderLastCardInZone(playerIdx, zoneName) {
|
||||
if (!gameState) return;
|
||||
const player = gameState.players[playerIdx];
|
||||
if (!player) return;
|
||||
const pNum = playerIdx + 1;
|
||||
|
||||
const zoneIdPrefix = (zoneName === 'exile') ? 'exile-zone-' : 'gv-zone-';
|
||||
const zoneEl = document.getElementById(zoneIdPrefix + pNum);
|
||||
if (!zoneEl) return;
|
||||
|
||||
const cards = player.zones[zoneName] || [];
|
||||
const lastCard = cards[cards.length - 1]; // most recently added card
|
||||
|
||||
if (lastCard && lastCard.imageUrl) {
|
||||
const safeImg = (lastCard.imageUrl || '').replace(/'/g, "\\'");
|
||||
const safeName = (lastCard.name || '').replace(/'/g, "\\'");
|
||||
const safeType = (lastCard.typeLine || '').replace(/'/g, "\\'");
|
||||
zoneEl.innerHTML =
|
||||
'<img src="' + safeImg + '" alt="' + (lastCard.name || zoneName) + '" ' +
|
||||
'style="width:100%;height:100%;object-fit:cover;border-radius:6px;" ' +
|
||||
'onmouseenter="showFloatingCardPreview(\'' + safeImg + '\', event, {name:\'' + safeName + '\', typeLine:\'' + safeType + '\'})" ' +
|
||||
'onmousemove="positionFloatingPreview(event)" ' +
|
||||
'onmouseleave="hideFloatingCardPreview()" ' +
|
||||
'draggable="false" />';
|
||||
// Always show count badge on top
|
||||
if (cards.length > 0) {
|
||||
zoneEl.innerHTML += '<span class="card-count" id="' + (zoneName === 'exile' ? 'exile' : 'gv') + '-count-' + pNum + '" ' +
|
||||
'style="display:block;">' + cards.length + '</span>';
|
||||
}
|
||||
} else {
|
||||
// Fallback: icon + name + count (original look)
|
||||
const icon = (zoneName === 'exile') ? '⬡' : '☠';
|
||||
const label = (zoneName === 'exile') ? 'Exile' : 'Grave';
|
||||
const countId = (zoneName === 'exile') ? 'exile-count-' : 'gv-count-';
|
||||
zoneEl.innerHTML =
|
||||
'<span class="card-count" id="' + countId + pNum + '" style="' + (cards.length > 0 ? 'display:block;' : 'display:none;') + '">' + cards.length + '</span>' +
|
||||
'<span class="zone-icon">' + icon + '</span>' +
|
||||
'<span class="zone-name">' + label + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HAND ZONE RENDERING (thumbnails in zone.hand)
|
||||
// ============================================================
|
||||
@@ -223,6 +263,7 @@ function renderHandZone(playerIdx) {
|
||||
if (!handEl) return;
|
||||
|
||||
const hand = player.zones.hand || [];
|
||||
const hideFaces = !!player.isAI; // AI hands render face-down
|
||||
|
||||
// Update count badge
|
||||
const countEl = document.getElementById('hand-count-' + pNum);
|
||||
@@ -240,13 +281,23 @@ function renderHandZone(playerIdx) {
|
||||
const maxShow = Math.min(hand.length, 7); // limit thumbnails
|
||||
for (let i = 0; i < maxShow; i++) {
|
||||
const card = hand[i];
|
||||
if (hideFaces) {
|
||||
// AI hand — show a face-down card back (no name, no image, no preview)
|
||||
thumbsHtml +=
|
||||
'<div class="hand-thumb face-down" ' +
|
||||
'data-player="' + playerIdx + '" data-hand-index="' + i + '" ' +
|
||||
'title="Opponent hand (hidden)"></div>';
|
||||
continue;
|
||||
}
|
||||
const imgSrc = card.imageUrl || '';
|
||||
const escapedName = (card.name || '').replace(/'/g, "\\'");
|
||||
const escapedType = (card.typeLine || '').replace(/'/g, "\\'");
|
||||
if (imgSrc) {
|
||||
thumbsHtml +=
|
||||
'<div class="hand-thumb" ' +
|
||||
'data-player="' + playerIdx + '" data-hand-index="' + i + '" ' +
|
||||
'onclick="openZoneModal(' + playerIdx + ')" ' +
|
||||
'onmousedown="startHandDrag(event,' + playerIdx + ',' + i + ',\'' + imgSrc.replace(/'/g, "\\'") + '\')" ' +
|
||||
'onmouseenter="showFloatingCardPreview(\'' + imgSrc.replace(/'/g, "\\'") + '\',event,{name:\'' + escapedName + '\',typeLine:\'' + escapedType + '\'})" ' +
|
||||
'onmousemove="positionFloatingPreview(event)" ' +
|
||||
'onmouseleave="hideFloatingCardPreview()">' +
|
||||
@@ -259,7 +310,11 @@ function renderHandZone(playerIdx) {
|
||||
}
|
||||
|
||||
if (hand.length > maxShow) {
|
||||
thumbsHtml += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
|
||||
if (hideFaces) {
|
||||
thumbsHtml += '<div class="hand-thumb face-down overflow" title="Opponent hand (hidden)">+' + (hand.length - maxShow) + '</div>';
|
||||
} else {
|
||||
thumbsHtml += '<div class="hand-thumb" onclick="openZoneModal(' + playerIdx + ')" style="background:rgba(255,255,255,0.05);display:flex;align-items:center;justify-content:center;font-size:9px;color:rgba(255,255,255,0.4);">+' + (hand.length - maxShow) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
handEl.innerHTML = thumbsHtml;
|
||||
@@ -290,7 +345,6 @@ function renderBattlefieldCard(cardData) {
|
||||
card.style.top = (cardData.y || 10) + 'px';
|
||||
|
||||
const playerColor = (gameState.players[cardData.ownerIndex] || {}).color || 'transparent';
|
||||
card.style.borderColor = playerColor;
|
||||
|
||||
if (cardData.tapped) card.classList.add('tapped');
|
||||
if (cardData.faceDown) card.classList.add('face-down');
|
||||
@@ -324,7 +378,6 @@ function renderBattlefieldCard(cardData) {
|
||||
card.innerHTML =
|
||||
protBadge +
|
||||
'<img src="' + imgUrl + '" alt="' + altName + '" draggable="false" loading="lazy"/>' +
|
||||
'<div class="card-owner-bar" style="background:' + playerColor + '"></div>' +
|
||||
kwBadgeHtml;
|
||||
|
||||
// Counter badges
|
||||
@@ -358,7 +411,7 @@ function renderBattlefieldCard(cardData) {
|
||||
card.appendChild(dmgBadge);
|
||||
}
|
||||
|
||||
// Event listeners — use both for compatibility (Comet: inline onclick already on container)
|
||||
// Event listeners — use both for compatibility (Comet: inline onclick already on container)
|
||||
card.addEventListener('mousedown', e => {
|
||||
if (e.button !== 0) return;
|
||||
if (cardData.attachedTo) return;
|
||||
@@ -523,6 +576,63 @@ document.addEventListener('mouseup', e => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// HAND-TO-BATTLEFIELD DRAG & DROP
|
||||
// ============================================================
|
||||
function startHandDrag(e, playerIdx, handIndex, imgSrc) {
|
||||
// Only left mouse button
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
isDraggingFromHand = true;
|
||||
handDragPlayerIdx = playerIdx;
|
||||
handDragCardIdx = handIndex;
|
||||
hideFloatingCardPreview();
|
||||
|
||||
// Create ghost element
|
||||
handDragGhost = document.createElement('div');
|
||||
handDragGhost.className = 'hand-drag-ghost';
|
||||
handDragGhost.innerHTML = '<img src="' + imgSrc + '" draggable="false"/>';
|
||||
handDragGhost.style.left = e.clientX + 'px';
|
||||
handDragGhost.style.top = e.clientY + 'px';
|
||||
document.body.appendChild(handDragGhost);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!isDraggingFromHand || !handDragGhost) return;
|
||||
handDragGhost.style.left = e.clientX + 'px';
|
||||
handDragGhost.style.top = e.clientY + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', function(e) {
|
||||
if (!isDraggingFromHand) return;
|
||||
|
||||
// Clean up ghost
|
||||
if (handDragGhost) {
|
||||
handDragGhost.remove();
|
||||
handDragGhost = null;
|
||||
}
|
||||
|
||||
// Check if dropped over a battle-zone
|
||||
const pNum = handDragPlayerIdx + 1;
|
||||
const battleZone = document.getElementById('battle-zone-' + pNum);
|
||||
if (battleZone) {
|
||||
const rect = battleZone.getBoundingClientRect();
|
||||
if (e.clientX >= rect.left && e.clientX <= rect.right &&
|
||||
e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
||||
// Play the card
|
||||
if (typeof playCardFromHand === 'function') {
|
||||
playCardFromHand(handDragPlayerIdx, handDragCardIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isDraggingFromHand = false;
|
||||
handDragPlayerIdx = null;
|
||||
handDragCardIdx = null;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// PHASE TRACKER
|
||||
// ============================================================
|
||||
@@ -570,7 +680,7 @@ function handlePhaseClick(phaseIdx) {
|
||||
// Just provide info about the phase, don't jump to it
|
||||
if (!gameState) return;
|
||||
const phase = PHASES[phaseIdx];
|
||||
if (phase) addLogEntry('Phase info: <strong>' + phase.label + '</strong> — ' + phase.tip);
|
||||
if (phase) addLogEntry('Phase info: <strong>' + phase.label + '</strong> — ' + phase.tip);
|
||||
}
|
||||
|
||||
function updateTurnDisplay() {
|
||||
@@ -595,29 +705,7 @@ function updateStormCounter() {
|
||||
// MANA POOL DISPLAY
|
||||
// ============================================================
|
||||
function renderManaPool(playerIdx) {
|
||||
if (!gameState) return;
|
||||
const pool = getManaPool(playerIdx !== undefined ? playerIdx : gameState.currentPlayerIndex);
|
||||
const display = document.getElementById('mana-pool-display');
|
||||
if (!display) return;
|
||||
|
||||
const colors = ['W', 'U', 'B', 'R', 'G', 'C'];
|
||||
let html = '<span class="mana-pool-label">MANA:</span>';
|
||||
let hasAny = false;
|
||||
|
||||
colors.forEach(c => {
|
||||
if (pool[c] > 0) {
|
||||
hasAny = true;
|
||||
html += '<span class="mana-pip mana-' + c + '" title="' + getManaColorName(c) + ': ' + pool[c] + '">' + pool[c] + '</span>';
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasAny) {
|
||||
html += '<span class="mana-empty">Empty</span>';
|
||||
}
|
||||
|
||||
html += '<button class="mana-pool-clear-btn" onclick="clearManaPool(' + gameState.currentPlayerIndex + ')" title="Clear mana pool">x</button>';
|
||||
display.innerHTML = html;
|
||||
display.style.display = gameState.inGame ? 'flex' : 'none';
|
||||
// mana pool display removed (Phase 1)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -768,6 +856,20 @@ function openZoneModal(playerId, zone) {
|
||||
}
|
||||
|
||||
function closeZoneModal() {
|
||||
// Auto-shuffle the library when closing, and ONLY the library
|
||||
if (zoneModalPlayerId !== null && gameState) {
|
||||
const player = gameState.players[zoneModalPlayerId];
|
||||
if (player && player.zones.library.length > 1) {
|
||||
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]];
|
||||
}
|
||||
addLogEntry('🔀 <strong>' + player.name + "'s</strong> library shuffled.");
|
||||
updateZoneCountsForPlayer(zoneModalPlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
const modal = document.getElementById('zone-modal');
|
||||
if (modal) modal.classList.remove('visible');
|
||||
zoneModalPlayerId = null;
|
||||
@@ -784,19 +886,28 @@ function renderZoneModal() {
|
||||
const player = gameState.players[zoneModalPlayerId];
|
||||
if (!player) return;
|
||||
|
||||
// AI players' hands are private — don't allow the Hand tab to be active
|
||||
if (player.isAI && activeZoneTab === 'hand') {
|
||||
activeZoneTab = 'graveyard';
|
||||
}
|
||||
|
||||
const titleEl = document.getElementById('zone-modal-title');
|
||||
if (titleEl) titleEl.textContent = player.name + "'s Zones";
|
||||
|
||||
// Render tabs
|
||||
const tabsEl = document.getElementById('zone-modal-tabs');
|
||||
if (tabsEl) {
|
||||
const zones = [
|
||||
let zones = [
|
||||
{ key: 'graveyard', icon: '☠', label: 'Graveyard' },
|
||||
{ key: 'exile', icon: '⬡', label: 'Exile' },
|
||||
{ key: 'command', icon: '👑', label: 'Command' },
|
||||
{ key: 'hand', icon: '✋', label: 'Hand' },
|
||||
{ key: 'library', icon: '📚', label: 'Library' },
|
||||
];
|
||||
// Strip the Hand tab when inspecting an AI opponent (hand is private)
|
||||
if (player.isAI) {
|
||||
zones = zones.filter(z => z.key !== 'hand');
|
||||
}
|
||||
|
||||
tabsEl.innerHTML = zones.map(z => {
|
||||
const count = (player.zones[z.key] || []).length;
|
||||
@@ -955,7 +1066,7 @@ function handleZmCtxAction(playerId, zone, cardIndex, action) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HAND AREA — full hand display (used in hand-area section, NOT the zone thumbnails)
|
||||
// HAND AREA — full hand display (used in hand-area section, NOT the zone thumbnails)
|
||||
// For the new layout, hand is rendered as thumbnails in the zone.hand element
|
||||
// ============================================================
|
||||
function renderHandArea() {
|
||||
@@ -1014,6 +1125,8 @@ function initSetupScreen() {
|
||||
'<button class="ai-diff-btn active" onclick="window._setAiDiff(this,\'easy\')" data-diff="easy" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:rgba(91,141,217,0.2);color:rgba(255,255,255,0.7);cursor:pointer;">Easy</button>' +
|
||||
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'normal\')" data-diff="normal" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Normal</button>' +
|
||||
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'hard\')" data-diff="hard" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Hard</button>' +
|
||||
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'aggro\')" data-diff="aggro" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Aggro</button>' +
|
||||
'<button class="ai-diff-btn" onclick="window._setAiDiff(this,\'combo\')" data-diff="combo" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid rgba(255,255,255,0.1);background:transparent;color:rgba(255,255,255,0.5);cursor:pointer;">Combo</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
@@ -1034,6 +1147,12 @@ function initSetupScreen() {
|
||||
'<div id="deck-assignment-grid" class="deck-assignment-grid"></div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="setup-section" style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap;">' +
|
||||
'<button class="setup-tool-btn" onclick="toggleDeckBuilderPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">🃏 Deck Builder</button>' +
|
||||
'<button class="setup-tool-btn" onclick="toggleDeckGenPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">⚡ Auto Gen</button>' +
|
||||
'<button class="setup-tool-btn" onclick="toggleCollectionPanel()" style="display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:6px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.04);color:rgba(255,255,255,0.7);cursor:pointer;font-size:12px;">📚 Collection</button>' +
|
||||
'</div>' +
|
||||
|
||||
'<button class="btn-start" onclick="startGame()">Begin Battle</button>' +
|
||||
'</div>';
|
||||
|
||||
@@ -1123,7 +1242,23 @@ function initSetupScreen() {
|
||||
// Sync AI config to engine globals
|
||||
setupAiPlayers = _localAiSet;
|
||||
setupAiDifficulty = _localAiDiff;
|
||||
initGame(selectedPlayerCount, playerNames, startingLife);
|
||||
setupPlayerCount = selectedPlayerCount;
|
||||
setupStartingLife = startingLife;
|
||||
|
||||
// Sync player names to engine globals
|
||||
for (let i = 0; i < playerNames.length; i++) {
|
||||
var nameInput = document.getElementById('player-name-' + i);
|
||||
if (nameInput) nameInput.value = playerNames[i];
|
||||
}
|
||||
|
||||
// If any deck choices were made, use startGameWithDecks (async deck loading)
|
||||
var hasDecks = typeof setupDeckChoices !== 'undefined' &&
|
||||
Object.keys(setupDeckChoices).length > 0;
|
||||
if (hasDecks && typeof startGameWithDecks === 'function') {
|
||||
startGameWithDecks();
|
||||
} else {
|
||||
initGame(selectedPlayerCount, playerNames, startingLife);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger display update for player count
|
||||
@@ -1237,6 +1372,34 @@ async function _loadBackendPrecons() {
|
||||
try {
|
||||
var precons = await AIBridge.deckSource.fetchPrecons();
|
||||
if (precons && precons.length > 0) {
|
||||
// Merge backend precons into PRECON_DATA so the UI can render them
|
||||
window.PRECON_DATA = precons.map(function(p) {
|
||||
return {
|
||||
id: (p.fileName || p.name || '').replace('.dck', ''),
|
||||
name: p.name || p.fileName || '',
|
||||
commander: p.commander || (p.commanders && p.commanders[0]) || '',
|
||||
set: p.set || '',
|
||||
fileName: p.fileName || '',
|
||||
colors: p.colors || [],
|
||||
year: p.year || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Re-render the precon tab content for all visible player panels
|
||||
var panels = document.querySelectorAll('[id^="deck-content-"]');
|
||||
panels.forEach(function(panel) {
|
||||
var idx = parseInt(panel.id.replace('deck-content-', ''));
|
||||
if (!isNaN(idx)) {
|
||||
var activeTab = panel.closest('.deck-select-panel');
|
||||
var activeBtn = activeTab ? activeTab.querySelector('.deck-tab.active') : null;
|
||||
// Only re-render if Precons tab is active (or no tab selected yet)
|
||||
if (!activeBtn || activeBtn.getAttribute('data-tab') === 'precons') {
|
||||
panel.innerHTML = _buildPreconTabContent(idx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update status indicator
|
||||
var indicator = document.getElementById('backend-status-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = '\u26a1 ' + precons.length + ' precons from backend';
|
||||
@@ -1280,18 +1443,83 @@ function switchDeckTab(playerIdx, tab) {
|
||||
case 'mydecks':
|
||||
content.innerHTML =
|
||||
'<div class="deck-mydecks-content">' +
|
||||
'<div class="deck-tab-empty">' +
|
||||
'<div style="margin-bottom:8px;">Import a decklist:</div>' +
|
||||
'<textarea class="deck-import-textarea" id="deck-import-' + playerIdx + '" ' +
|
||||
'placeholder="Paste decklist here...\n1 Sol Ring\n1 Command Tower\n..."></textarea>' +
|
||||
'<button class="btn btn-sm" style="margin-top:6px;" ' +
|
||||
'onclick="importDeckFromTextarea(' + playerIdx + ')">Import Decklist</button>' +
|
||||
'<div class="deck-mydecks-list" id="mydecks-list-' + playerIdx + '">' +
|
||||
'<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">Loading user decks...</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
_loadMyDecksTab(playerIdx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function _loadMyDecksTab(playerIdx) {
|
||||
var listEl = document.getElementById('mydecks-list-' + playerIdx);
|
||||
if (!listEl) return;
|
||||
|
||||
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
|
||||
fetch(apiBase + '/api/lab/decks')
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
var decks = (data.decks || []).filter(function(d) { return d.source === 'deckbuilder'; });
|
||||
if (decks.length === 0) {
|
||||
listEl.innerHTML = '<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">No saved decks. Use the Deck Builder or Auto Gen to create one.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
decks.forEach(function(d) {
|
||||
var name = d.name || 'Untitled';
|
||||
var cmdr = d.commander || d.commander_name || '';
|
||||
var count = d.totalCards || d.card_count || d.total_cards || 0;
|
||||
html += '<button class="deck-precon-item" onclick="selectUserDeck(' + playerIdx + ',' + d.deck_id + ',\'' + name.replace(/'/g, "\\'") + '\')">' +
|
||||
'<span class="deck-precon-name">' + name.replace(/</g, '<') + '</span>' +
|
||||
'<span class="deck-precon-cmdr">' + (cmdr ? cmdr.replace(/</g, '<') + ' \u2014 ' : '') + count + ' cards</span>' +
|
||||
'</button>';
|
||||
});
|
||||
listEl.innerHTML = html;
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.warn('[_loadMyDecksTab] Error:', e);
|
||||
listEl.innerHTML = '<div style="color:var(--color-text-faint,#888);font-size:12px;padding:12px;">Could not load decks. Is the server running?</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function selectUserDeck(playerIdx, deckId, deckName) {
|
||||
var statusEl = document.getElementById('deck-status-' + playerIdx);
|
||||
if (statusEl) { statusEl.textContent = 'Exporting...'; statusEl.className = 'deck-select-status'; }
|
||||
|
||||
// Highlight selected button
|
||||
var listEl = document.getElementById('mydecks-list-' + playerIdx);
|
||||
if (listEl) {
|
||||
listEl.querySelectorAll('.deck-precon-item').forEach(function(b) { b.classList.remove('selected'); });
|
||||
// Find the clicked button
|
||||
listEl.querySelectorAll('.deck-precon-item').forEach(function(b) {
|
||||
if (b.querySelector('.deck-precon-name') && b.querySelector('.deck-precon-name').textContent === deckName) {
|
||||
b.classList.add('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export to .dck then store the choice
|
||||
var apiBase = (typeof LAB_API_BASE !== 'undefined' && LAB_API_BASE) ? LAB_API_BASE : '';
|
||||
fetch(apiBase + '/api/decks/' + deckId + '/export-to-sim', { method: 'POST' })
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(result) {
|
||||
if (result.success) {
|
||||
if (typeof setupDeckChoices !== 'undefined') {
|
||||
setupDeckChoices[playerIdx] = { type: 'user-deck', deckName: result.deckName, fileName: result.deckName + '.dck' };
|
||||
}
|
||||
if (statusEl) { statusEl.textContent = deckName + ' (' + (result.totalCards || '?') + ' cards)'; statusEl.className = 'deck-select-status loaded'; }
|
||||
} else {
|
||||
if (statusEl) { statusEl.textContent = 'Export failed'; statusEl.className = 'deck-select-status'; }
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error('[selectUserDeck] Export failed:', e);
|
||||
if (statusEl) { statusEl.textContent = 'Export error'; statusEl.className = 'deck-select-status'; }
|
||||
});
|
||||
}
|
||||
|
||||
function filterPrecons(playerIdx, query) {
|
||||
var list = document.getElementById('precon-list-' + playerIdx);
|
||||
if (!list) return;
|
||||
@@ -1433,15 +1661,9 @@ function initGame(playerCount, playerNames, startingLife) {
|
||||
}
|
||||
}
|
||||
|
||||
// Assign precon decks if selected
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const sel = document.getElementById('deck-sel-' + i);
|
||||
if (sel && sel.value) {
|
||||
const preconList2 = (typeof PRECON_DATA !== 'undefined') ? PRECON_DATA : (typeof PRECONS !== 'undefined') ? PRECONS : [];
|
||||
const precon = preconList2.find(p => p.id === sel.value);
|
||||
if (precon) loadPreconDeck(i, precon);
|
||||
}
|
||||
}
|
||||
// NOTE: Precon deck loading is handled by the "Begin Battle" button
|
||||
// which calls startGameWithDecks() when setupDeckChoices has entries.
|
||||
// The old deck-sel-{i} dropdown path is removed.
|
||||
|
||||
// Mark AI players
|
||||
setupAiPlayers.forEach(function(idx) {
|
||||
@@ -1835,10 +2057,7 @@ function showToast(message) {
|
||||
// MANA POOL DISPLAY BUTTON
|
||||
// ============================================================
|
||||
function showManaPool() {
|
||||
const display = document.getElementById('mana-pool-display');
|
||||
if (display) {
|
||||
display.style.display = display.style.display === 'none' ? 'flex' : 'none';
|
||||
}
|
||||
// mana pool display removed (Phase 1)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1965,8 +2184,7 @@ function renderSettingsPanel() {
|
||||
'<div class="settings-row"><span>Commander Tax</span>' + renderToggle('commanderTaxEnabled', re.commanderTaxEnabled) + '</div>' +
|
||||
'<div class="settings-row"><span>Auto Resolve Triggers</span>' + renderToggle('autoResolveTriggers', re.autoResolveTriggers) + '</div>' +
|
||||
'<div class="settings-row"><span>Enforce Hand Size</span>' + renderToggle('enforceHandSize', re.enforceHandSize) + '</div>' +
|
||||
'<div class="settings-row"><span>Auto Eliminate on SBA</span>' + renderToggle('autoEliminateOnSBA', re.autoEliminateOnSBA) + '</div>' +
|
||||
'<div class="settings-row"><span>Mana Auto-Clear</span>' + renderToggle('manaAutoClear', gameState.manaAutoClear) + '</div>';
|
||||
'<div class="settings-row"><span>Auto Eliminate on SBA</span>' + renderToggle('autoEliminateOnSBA', re.autoEliminateOnSBA) + '</div>';
|
||||
}
|
||||
|
||||
function renderToggle(key, value) {
|
||||
@@ -1986,7 +2204,7 @@ function toggleSetting(key) {
|
||||
|
||||
// ============================================================
|
||||
// PLACEHOLDER STUBS for engine-called UI functions
|
||||
// (engine.js calls these — we provide no-op or simple implementations)
|
||||
// (engine.js calls these — we provide no-op or simple implementations)
|
||||
// ============================================================
|
||||
|
||||
function renderPlayerPanelsNoOp() { renderPlayerPanels(); }
|
||||
@@ -2218,7 +2436,7 @@ function openPreconBrowser() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM UTILITY — updateCardCounterBadge
|
||||
// DOM UTILITY — updateCardCounterBadge
|
||||
// ============================================================
|
||||
function updateCardCounterBadge(cardId) {
|
||||
if (!gameState) return;
|
||||
@@ -2252,7 +2470,7 @@ function updateCardCounterBadge(cardId) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM — toggleTap visual update
|
||||
// DOM — toggleTap visual update
|
||||
// ============================================================
|
||||
function _refreshCardEl(cardId) {
|
||||
if (!gameState) return;
|
||||
@@ -2265,7 +2483,7 @@ function _refreshCardEl(cardId) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOMContentLoaded — wire up everything
|
||||
// DOMContentLoaded — wire up everything
|
||||
// ============================================================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initSetupScreen();
|
||||
|
||||
+204
-83
@@ -4,10 +4,15 @@ 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:
|
||||
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
|
||||
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
|
||||
|
||||
Macro-actions (from ml.config.scope):
|
||||
0: CAST_CREATURE — Play a creature
|
||||
@@ -41,11 +46,6 @@ 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,45 +62,52 @@ 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", "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",
|
||||
"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",
|
||||
}
|
||||
|
||||
# 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", "signets", # catch-all partial
|
||||
"talisman", # catch-all for talisman cycle
|
||||
"coalition relic", "prismatic lens",
|
||||
"cultivate", "kodama's reach", "rampant growth", "farseek",
|
||||
"nature's lore", "three visits", "skyshroud claim",
|
||||
"explosive vegetation", "migration path", "circuitous route",
|
||||
@@ -112,13 +119,144 @@ 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",
|
||||
}
|
||||
|
||||
# Known creature-type indicators
|
||||
CREATURE_KEYWORDS = {
|
||||
"creature", "token", "enters the battlefield",
|
||||
# Partial-match prefixes (for cycles like Signets, Talismans)
|
||||
RAMP_PARTIAL_PREFIXES = {
|
||||
"talisman", "signet", "locket", "banner", "obelisk",
|
||||
"keyrune", "cluestone", "monolith", "medallion",
|
||||
}
|
||||
|
||||
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:
|
||||
"""
|
||||
@@ -133,74 +271,52 @@ 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()
|
||||
|
||||
# ── Direct type matching ────────────────────────────
|
||||
players = decision.get("players", [])
|
||||
active_seat = decision.get("active_seat", 0)
|
||||
|
||||
# ── Unambiguous direct-type matches ──────────────────────
|
||||
|
||||
# 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 → classify as CAST_RAMP (developing mana is ramp)
|
||||
# Land play → developing mana base = ramp
|
||||
if action_type == "land":
|
||||
return ACTION_TO_IDX[MacroAction.CAST_RAMP]
|
||||
|
||||
# Pass / hold mana
|
||||
# Pass / hold mana — check available mana to distinguish
|
||||
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: classify by card identity ─────────
|
||||
# ── Cast actions ──────────────────────────────────────
|
||||
|
||||
if action_type == "cast":
|
||||
# 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 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]
|
||||
|
||||
if card_name in DRAW_CARD_NAMES or _partial_match(card_name, DRAW_CARD_NAMES):
|
||||
return ACTION_TO_IDX[MacroAction.CAST_DRAW]
|
||||
# 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 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 ────────────────────────────────────────
|
||||
# ── Fallback ──────────────────────────────────────────
|
||||
return ACTION_TO_IDX[MacroAction.PASS]
|
||||
|
||||
|
||||
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
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# File-level helpers
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
||||
"""
|
||||
@@ -214,7 +330,7 @@ def label_decisions_file(jsonl_path: str) -> Tuple[np.ndarray, List[dict]]:
|
||||
labels = []
|
||||
raw = []
|
||||
|
||||
with open(jsonl_path, "r") as f:
|
||||
with open(jsonl_path, "r", encoding="utf-8") as f:
|
||||
for i, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -232,7 +348,11 @@ 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."""
|
||||
"""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.
|
||||
"""
|
||||
dist = {}
|
||||
for action in MacroAction:
|
||||
idx = ACTION_TO_IDX[action]
|
||||
@@ -242,10 +362,11 @@ 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(f"{'─' * 43}")
|
||||
logger.info("─" * 43)
|
||||
logger.info(f" {'TOTAL':<23} {total:>8}")
|
||||
|
||||
return dist
|
||||
+9
-1
@@ -292,7 +292,15 @@ class DistillationDefaults:
|
||||
ppo_episodes_per_iter: int = 64
|
||||
ppo_lr: float = 3e-4
|
||||
ppo_clip_epsilon: float = 0.2
|
||||
ppo_entropy_coeff: float = 0.01
|
||||
# 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_eval_episodes: int = 100
|
||||
|
||||
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
|
||||
|
||||
+33
-18
@@ -6,8 +6,9 @@ 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 both Forge sim files (ml-decisions-sim-*.jsonl) and
|
||||
PPO self-play files (ml-decisions-ppo-*.jsonl)
|
||||
- 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"
|
||||
- Configurable source_weights for sampling ratios
|
||||
- min_reward_threshold filter for PPO data quality
|
||||
- Each sample tagged with source for downstream analysis
|
||||
@@ -21,7 +22,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" or "ppo")
|
||||
sources: object (N,) — data source ("forge", "ppo", or "ds")
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -62,11 +63,11 @@ class DatasetConfig:
|
||||
embeddings_dir: Optional[str] = None
|
||||
max_samples: Optional[int] = None
|
||||
|
||||
# Source weights control sampling ratios between Forge and PPO data.
|
||||
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
|
||||
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
|
||||
# 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: Dict[str, float] = field(
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
|
||||
)
|
||||
|
||||
# Minimum reward threshold for PPO data — only include decisions
|
||||
@@ -81,12 +82,13 @@ class DatasetConfig:
|
||||
|
||||
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
||||
"""
|
||||
Scan results directory for both Forge sim and PPO decision files.
|
||||
Scan results directory for Forge sim, PPO, and DeepSeek 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 = {}
|
||||
@@ -101,10 +103,16 @@ def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
|
||||
if ppo_files:
|
||||
sources["ppo"] = ppo_files
|
||||
|
||||
# Also pick up legacy files that don't have sim/ppo prefix
|
||||
# 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)
|
||||
legacy_files = sorted(
|
||||
f for f in results_path.glob("ml-decisions-*.jsonl")
|
||||
if f not in set(forge_files + ppo_files)
|
||||
if f not in known
|
||||
)
|
||||
if legacy_files:
|
||||
sources.setdefault("forge", []).extend(legacy_files)
|
||||
@@ -123,7 +131,7 @@ def _passes_reward_filter(
|
||||
) -> bool:
|
||||
"""
|
||||
Filter PPO decisions by reward threshold.
|
||||
Forge data always passes. PPO data must meet the threshold.
|
||||
Forge and DS data always passes. PPO data must meet the threshold.
|
||||
"""
|
||||
if source != "ppo":
|
||||
return True
|
||||
@@ -155,7 +163,7 @@ def _apply_source_weights(
|
||||
if weight <= 0:
|
||||
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
|
||||
continue
|
||||
if weight >= 1.0 and weight == 1.0:
|
||||
if weight == 1.0:
|
||||
combined.extend(samples)
|
||||
elif weight < 1.0:
|
||||
# Subsample
|
||||
@@ -193,8 +201,9 @@ def build_dataset(
|
||||
Build complete training dataset from all ML decision files.
|
||||
|
||||
Phase 2 additions:
|
||||
- Scans both Forge and PPO JSONL files
|
||||
- Scans Forge, PPO, and DeepSeek (ds) 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
|
||||
|
||||
@@ -202,14 +211,15 @@ 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.5)
|
||||
source_weights: Dict of source → sampling weight
|
||||
(default: forge=1.0, ppo=0.0, ds=4.0)
|
||||
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}
|
||||
source_weights = {"forge": 1.0, "ppo": 0.0, "ds": 4.0}
|
||||
|
||||
# Load card embeddings
|
||||
card_index = CardEmbeddingIndex(embeddings_dir)
|
||||
@@ -225,7 +235,7 @@ def build_dataset(
|
||||
|
||||
encoder = StateEncoder(card_index)
|
||||
|
||||
# --- Phase 2 Task 1: Discover files from both sources ---
|
||||
# --- Phase 2 Task 1: Discover files from all sources ---
|
||||
source_files = _discover_jsonl_files(results_dir)
|
||||
if not source_files:
|
||||
logger.error("No ML decision files found in %s", results_dir)
|
||||
@@ -256,7 +266,7 @@ def build_dataset(
|
||||
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
|
||||
file_count = 0
|
||||
|
||||
with open(jsonl_file, "r") as f:
|
||||
with open(jsonl_file, "r", encoding="utf-8") as f:
|
||||
for line_num, line in enumerate(f):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -503,7 +513,11 @@ def main():
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ppo-weight", type=float, default=0.0,
|
||||
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
|
||||
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)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-reward", type=float, default=0.0,
|
||||
@@ -515,6 +529,7 @@ 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") as f:
|
||||
with open(report_path, "w", encoding="utf-8") 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") as f:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
logger.info("Verdict history saved: %s (%d entries)", path, len(history))
|
||||
@@ -141,8 +141,17 @@ class CardEmbeddingIndex:
|
||||
logger.error("Auto-download failed: %s", e)
|
||||
return None
|
||||
|
||||
def get_embedding(self, card_name: str) -> np.ndarray:
|
||||
"""Get embedding vector for a card name. Returns zero vector if not found."""
|
||||
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
|
||||
if not self._loaded:
|
||||
return self._zero_vec
|
||||
idx = self._name_to_idx.get(card_name.lower())
|
||||
@@ -155,8 +164,11 @@ class CardEmbeddingIndex:
|
||||
return self._embeddings[idx]
|
||||
return self._zero_vec
|
||||
|
||||
def mean_pool_zone(self, card_names: List[str]) -> np.ndarray:
|
||||
"""Mean-pool embeddings for a list of card names (a game zone)."""
|
||||
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.
|
||||
"""
|
||||
if not card_names:
|
||||
return self._zero_vec
|
||||
vectors = [self.get_embedding(name) for name in card_names]
|
||||
@@ -167,6 +179,24 @@ 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.
|
||||
@@ -241,6 +271,10 @@ 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 = []
|
||||
|
||||
@@ -250,7 +284,8 @@ class StateEncoder:
|
||||
features.append(life / 40.0)
|
||||
|
||||
# idx 1: commander damage taken
|
||||
cmdr_dmg = p.get("cmdr_dmg", 0)
|
||||
# _resolve_cmdr_dmg handles dict (live Forge IPC) and int (JSONL/pre-collapsed)
|
||||
cmdr_dmg = _resolve_cmdr_dmg(p.get("cmdr_dmg", 0))
|
||||
features.append(cmdr_dmg / 21.0)
|
||||
|
||||
# idx 2: mana available
|
||||
@@ -380,7 +415,7 @@ def encode_decisions_file(
|
||||
states = []
|
||||
raw = []
|
||||
|
||||
with open(jsonl_path, "r") as f:
|
||||
with open(jsonl_path, "r", encoding="utf-8") 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") as f:
|
||||
with open(self.path, "r", encoding="utf-8") 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") as f:
|
||||
with open(self.path, "w", encoding="utf-8") 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) as f:
|
||||
with open(batch_path, encoding="utf-8") 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") as f:
|
||||
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)
|
||||
@@ -0,0 +1,548 @@
|
||||
"""
|
||||
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") as f:
|
||||
with open(output_path, "w", encoding="utf-8") 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") as f:
|
||||
with open(dck_file, "r", encoding="utf-8") 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") as f:
|
||||
with open(args.output, "w", encoding="utf-8") 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) as fh:
|
||||
with open(f, encoding="utf-8") 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) as f:
|
||||
with open(filepath, encoding="utf-8") 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) as f:
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
total = sum(1 for _ in f)
|
||||
print(f"\n Showing {count} of {total:,} total decisions.\n")
|
||||
|
||||
|
||||
+48
-13
@@ -52,14 +52,44 @@ from ml.config.scope import (
|
||||
)
|
||||
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
|
||||
|
||||
logger = logging.getLogger("ml.serving")
|
||||
# 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
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -100,7 +130,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
|
||||
@@ -146,7 +176,7 @@ class PolicyInferenceService:
|
||||
|
||||
except Exception as e:
|
||||
self._load_error = str(e)
|
||||
logger.error("Failed to load checkpoint: %s", e)
|
||||
logger.error("Failed to load checkpoint: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
def reload(self, checkpoint_path: str = None) -> bool:
|
||||
@@ -178,7 +208,7 @@ class PolicyInferenceService:
|
||||
|
||||
except Exception as e:
|
||||
self._load_error = str(e)
|
||||
logger.error("Hot-reload failed: %s", e)
|
||||
logger.error("Hot-reload failed: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
def predict(
|
||||
@@ -224,7 +254,10 @@ class PolicyInferenceService:
|
||||
|
||||
# Run inference
|
||||
with torch.no_grad():
|
||||
logits = self.model(state_tensor)
|
||||
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)
|
||||
|
||||
if greedy:
|
||||
action_idx = logits.argmax(dim=-1).item()
|
||||
@@ -254,7 +287,7 @@ class PolicyInferenceService:
|
||||
|
||||
except Exception as e:
|
||||
elapsed_ms = (time.time() - t_start) * 1000
|
||||
logger.error("Inference error: %s", e)
|
||||
logger.error("Inference error: %s", e, exc_info=True)
|
||||
return {
|
||||
"error": str(e),
|
||||
"inference_ms": round(elapsed_ms, 2),
|
||||
@@ -286,7 +319,9 @@ class PolicyInferenceService:
|
||||
|
||||
# Batch inference
|
||||
with torch.no_grad():
|
||||
logits = self.model(state_tensor)
|
||||
raw_output = self.model(state_tensor)
|
||||
# Same guard as predict() -- unwrap before softmax/argmax.
|
||||
logits = _unwrap_logits(raw_output)
|
||||
probs = torch.softmax(logits, dim=-1).cpu().numpy()
|
||||
|
||||
if greedy:
|
||||
@@ -312,7 +347,7 @@ class PolicyInferenceService:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Batch inference error: %s", e)
|
||||
logger.error("Batch inference error: %s", e, exc_info=True)
|
||||
results = [{"error": str(e)}] * len(snapshots)
|
||||
|
||||
return results
|
||||
@@ -331,9 +366,9 @@ class PolicyInferenceService:
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# ==========================================================
|
||||
# Standalone FastAPI Server
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# ==========================================================
|
||||
|
||||
def create_standalone_app():
|
||||
"""Create a standalone FastAPI app for the policy server."""
|
||||
@@ -341,7 +376,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",
|
||||
)
|
||||
@@ -424,7 +459,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") as f:
|
||||
with open(filepath, "w", encoding="utf-8") 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") as f:
|
||||
with open(path, "w", encoding="utf-8") 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") as f:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, indent=2, default=str)
|
||||
logger.info(" Summary saved: %s", path)
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ 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")
|
||||
|
||||
@@ -51,6 +52,8 @@ 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
|
||||
@@ -61,6 +64,8 @@ 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:
|
||||
@@ -70,6 +75,10 @@ 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
|
||||
@@ -82,7 +91,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") as f:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -95,9 +104,44 @@ 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.
|
||||
|
||||
@@ -111,6 +155,8 @@ 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()
|
||||
@@ -130,20 +176,26 @@ def snapshots_to_episode(
|
||||
except (ValueError, KeyError):
|
||||
action_idx = ACTION_TO_IDX[MacroAction.PASS]
|
||||
|
||||
# Reward
|
||||
# Reward — use correct reward.py signatures
|
||||
is_done = i == len(snapshots) - 1
|
||||
if is_done and "outcome" in snap:
|
||||
outcome = snap["outcome"]
|
||||
reward = compute_terminal_reward(
|
||||
won=snap["outcome"].get("won", False),
|
||||
turns=snap.get("turn", 1),
|
||||
life_remaining=snap["outcome"].get("life_remaining", 0),
|
||||
won=outcome.get("won", False),
|
||||
draw=outcome.get("draw", False),
|
||||
timeout=outcome.get("timeout", False),
|
||||
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(
|
||||
life_delta=snap.get("life_delta", 0),
|
||||
cards_drawn=snap.get("cards_drawn", 0),
|
||||
creatures_played=snap.get("creatures_played", 0),
|
||||
prev_state=prev_state,
|
||||
curr_state=curr_state,
|
||||
action_taken=action_label,
|
||||
agent_seat=agent_seat,
|
||||
config=rc,
|
||||
)
|
||||
|
||||
@@ -154,6 +206,7 @@ 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))
|
||||
@@ -163,6 +216,61 @@ 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)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
@@ -271,6 +379,7 @@ 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.
|
||||
@@ -279,6 +388,11 @@ 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.
|
||||
"""
|
||||
@@ -292,6 +406,14 @@ 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
|
||||
|
||||
@@ -322,12 +444,24 @@ async def run_forge_pipeline(
|
||||
max_queue: int = 256,
|
||||
on_batch_ready=None,
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
) -> Dict[str, int]:
|
||||
exporter: Optional[DecisionExporter] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""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)
|
||||
|
||||
@@ -336,16 +470,25 @@ async def run_forge_pipeline(
|
||||
reward_config=reward_config)
|
||||
)
|
||||
consumer_task = asyncio.create_task(
|
||||
ppo_consumer(queue, buffer_size, on_batch_ready)
|
||||
ppo_consumer(queue, buffer_size, on_batch_ready, exporter=exporter)
|
||||
)
|
||||
|
||||
produced = await producer_task
|
||||
queue.close()
|
||||
consumed = await consumer_task
|
||||
|
||||
stats = {
|
||||
# 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] = {
|
||||
"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") as f:
|
||||
with open(history_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.history, f, indent=2, default=str)
|
||||
|
||||
summary = {
|
||||
|
||||
+10
-4
@@ -322,7 +322,7 @@ class SupervisedTrainer:
|
||||
}
|
||||
serializable_history.append(entry)
|
||||
|
||||
with open(history_path, "w") as f:
|
||||
with open(history_path, "w", encoding="utf-8") 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_mem / (1024**3)
|
||||
mem = torch.cuda.get_device_properties(0).total_memory / (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,6 +562,12 @@ 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"
|
||||
)
|
||||
@@ -638,7 +644,7 @@ def main():
|
||||
|
||||
# Save evaluation results
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w") as f:
|
||||
with open(eval_path, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
logger.info(f" Results saved: {eval_path}")
|
||||
return
|
||||
@@ -696,7 +702,7 @@ def main():
|
||||
print_evaluation(results)
|
||||
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w") as f:
|
||||
with open(eval_path, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
logger.info(" Eval results saved: %s", eval_path)
|
||||
else:
|
||||
|
||||
@@ -35,6 +35,7 @@ class Config:
|
||||
forge_jar: str = ""
|
||||
forge_dir: str = ""
|
||||
forge_decks_dir: str = ""
|
||||
user_decks_dir: str = ""
|
||||
lab_jar: str = ""
|
||||
precon_dir: str = ""
|
||||
results_dir: str = "results"
|
||||
@@ -57,6 +58,10 @@ 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:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
unzip mtg-commander-lan.zip
|
||||
cd mtg-commander-lan
|
||||
npm install
|
||||
node server.js
|
||||
+19
-5
@@ -50,6 +50,7 @@ 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,
|
||||
@@ -279,14 +280,21 @@ def run_update_weights(cfg):
|
||||
return False
|
||||
|
||||
|
||||
def start_training(base_url, epochs, lr, batch_size, patience):
|
||||
"""Trigger ML training and return immediately."""
|
||||
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.
|
||||
"""
|
||||
resp = api_post(base_url, "/api/ml/train", {
|
||||
"epochs": epochs,
|
||||
"lr": lr,
|
||||
"batchSize": batch_size,
|
||||
"patience": patience,
|
||||
"rebuildDataset": True,
|
||||
"dsWeight": ds_weight,
|
||||
})
|
||||
return resp
|
||||
|
||||
@@ -416,6 +424,7 @@ 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:
|
||||
@@ -510,7 +519,8 @@ 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"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}, "
|
||||
f"DS Weight: {cfg['train_ds_weight']}x")
|
||||
|
||||
try:
|
||||
start_training(
|
||||
@@ -519,6 +529,7 @@ 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)
|
||||
@@ -556,8 +567,8 @@ Examples:
|
||||
# Just run sims, skip both weight update and training:
|
||||
python overnight-run.py --no-train --no-update-weights
|
||||
|
||||
# Custom training settings:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20
|
||||
# Custom training settings with higher DS upsampling:
|
||||
python overnight-run.py --epochs 200 --lr 0.0005 --patience 20 --ds-weight 6.0
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--url", default="http://localhost:8080",
|
||||
@@ -590,6 +601,8 @@ 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,
|
||||
@@ -611,6 +624,7 @@ 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
|
||||
|
||||
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "MTG Commander (HTML)",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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')
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/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.")
|
||||
+35
-7
@@ -245,6 +245,20 @@ 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)
|
||||
@@ -782,10 +796,12 @@ async def export_deck_to_sim(deck_id: int):
|
||||
if not safe_name:
|
||||
safe_name = f"deck_{deck_id}"
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
@@ -872,7 +888,11 @@ async def import_decklist(deck_id: int, request: FastAPIRequest):
|
||||
"""
|
||||
deck = _get_deck_or_404(deck_id)
|
||||
body = await request.json()
|
||||
text = body.get('text', '')
|
||||
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)
|
||||
clear_first = body.get('clearFirst', False)
|
||||
|
||||
if not text.strip():
|
||||
@@ -964,8 +984,12 @@ async def import_decklist_new(request: FastAPIRequest):
|
||||
Body: { "text": "...", "name": "My Deck" }
|
||||
"""
|
||||
body = await request.json()
|
||||
text = body.get('text', '')
|
||||
deck_name = body.get('name', '').strip()
|
||||
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 '')
|
||||
|
||||
if not text.strip():
|
||||
return JSONResponse({'error': 'No decklist text provided'}, status_code=400)
|
||||
@@ -1048,6 +1072,10 @@ 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)
|
||||
|
||||
+8
-7
@@ -861,12 +861,13 @@ 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']
|
||||
@@ -1320,7 +1321,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', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'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,
|
||||
'embeddings_loaded': False,
|
||||
'embedding_cards': 0,
|
||||
'error': _coach._deck_gen_v3_error,
|
||||
@@ -1341,7 +1342,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 Ollama is running with gpt-oss:20b model.')
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure the Anthropic API key is configured.')
|
||||
|
||||
if not req.commander_name or len(req.commander_name.strip()) < 2:
|
||||
raise HTTPException(400, 'Commander name is required (min 2 chars)')
|
||||
|
||||
+3
-1
@@ -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') as f:
|
||||
with open(batch_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(batch_data, f, indent=2, default=str)
|
||||
logger.info(f'Saved batch results to {batch_path}')
|
||||
|
||||
@@ -516,6 +516,8 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""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)
|
||||
+209
-65
@@ -1,11 +1,29 @@
|
||||
"""Live game session routes.
|
||||
|
||||
Endpoints:
|
||||
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)
|
||||
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
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
@@ -13,14 +31,39 @@ 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
|
||||
@@ -31,12 +74,16 @@ 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):
|
||||
@@ -54,73 +101,139 @@ class GameActionResponse(BaseModel):
|
||||
action: str
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# In-memory session store (replace with DB / Redis for persistence)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory session store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# { game_id: { "seats": [...], "status": "active" | "ended", "pending_actions": [...] } }
|
||||
# { game_id: { "seats": [...], "status": "active"|"ended"|"error",
|
||||
# "batch_state": BatchState|None, "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 a new live game session.
|
||||
"""Create and launch a live Forge game session.
|
||||
|
||||
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}.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
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",
|
||||
game_id,
|
||||
len(req.seats),
|
||||
"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,
|
||||
)
|
||||
)
|
||||
|
||||
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 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)
|
||||
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.
|
||||
"""
|
||||
valid_actions = {
|
||||
"pass_priority", "end_turn",
|
||||
@@ -142,47 +255,73 @@ 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 session '{req.game_id}' is not active (status={session['status']}).",
|
||||
detail=f"Game '{req.game_id}' is not active (status={session['status']}).",
|
||||
)
|
||||
|
||||
action_record = {
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
session["pending_actions"].append({
|
||||
"seat": req.seat,
|
||||
"action": req.action,
|
||||
"target_id": req.target_id,
|
||||
"extra": req.extra,
|
||||
"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"]),
|
||||
}
|
||||
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 endpoint)
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/game/{game_id}/actions (Forge IPC polling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/game/{game_id}/actions")
|
||||
async def poll_actions(game_id: str):
|
||||
"""Drain and return all pending human actions for a game session.
|
||||
"""Drain and return all pending human actions (consume-once).
|
||||
|
||||
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:
|
||||
@@ -193,17 +332,22 @@ 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 active game sessions (debug endpoint)."""
|
||||
"""List all tracked game sessions (debug)."""
|
||||
return {
|
||||
"count": len(_sessions),
|
||||
"sessions": [
|
||||
{"game_id": gid, "status": s["status"], "seats": len(s["seats"])}
|
||||
{
|
||||
"game_id": gid,
|
||||
"status": s["status"],
|
||||
"seats": len(s["seats"]),
|
||||
"pending": len(s["pending_actions"]),
|
||||
}
|
||||
for gid, s in _sessions.items()
|
||||
],
|
||||
}
|
||||
+81
-3
@@ -14,6 +14,8 @@ 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
|
||||
|
||||
@@ -96,6 +98,7 @@ 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):
|
||||
@@ -106,7 +109,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.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
|
||||
output_path = os.path.abspath(os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")) # FARMHOUSE_RESULTS_ABS
|
||||
state.result_path = output_path
|
||||
|
||||
# Auto-export deckbuilder decks to .dck if not already exported
|
||||
@@ -257,7 +260,14 @@ async def list_decks():
|
||||
try:
|
||||
conn = _get_db_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
|
||||
"""
|
||||
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
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
db_name = row["name"]
|
||||
@@ -268,6 +278,7 @@ 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:
|
||||
@@ -444,7 +455,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[-200:]}
|
||||
return {"lines": state.log_lines[-2000:]}
|
||||
|
||||
|
||||
@router.get("/api/lab/debug-log")
|
||||
@@ -468,6 +479,73 @@ 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):
|
||||
|
||||
+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) as fh:
|
||||
with open(f, encoding="utf-8") 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) as f:
|
||||
with open(filepath, encoding="utf-8") 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") as f:
|
||||
with open(eval_path, "w", encoding="utf-8") 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") as fh:
|
||||
with open(f, "r", encoding="utf-8") 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") as f:
|
||||
with open(eval_path, "r", encoding="utf-8") 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") as f:
|
||||
with open(output_path, "w", encoding="utf-8") 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") as f:
|
||||
with open(results_path, "r", encoding="utf-8") 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") as f:
|
||||
with open(eval_path, "r", encoding="utf-8") 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") as f:
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
result["generations"] = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if summary_path.exists():
|
||||
try:
|
||||
with open(summary_path, "r") as f:
|
||||
with open(summary_path, "r", encoding="utf-8") 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") as f:
|
||||
with open(history_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+37
-7
@@ -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,7 +24,8 @@ import numpy as np
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger("routes.policy")
|
||||
# Unique logger name so log lines are attributable to this module specifically
|
||||
logger = logging.getLogger("routes.policy.decide")
|
||||
|
||||
router = APIRouter(prefix="/api/policy", tags=["policy"])
|
||||
|
||||
@@ -310,7 +311,10 @@ async def decide(req: DecideRequest):
|
||||
req.snapshot_schema, req.resolved_turn(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Precomputed vector encode failed (%s) — falling back", e)
|
||||
logger.warning(
|
||||
"Precomputed vector encode failed — falling back to full encoder",
|
||||
exc_info=True,
|
||||
)
|
||||
state_vec = None
|
||||
else:
|
||||
state_vec = None
|
||||
@@ -338,6 +342,24 @@ 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)
|
||||
@@ -373,7 +395,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)
|
||||
logger.error("Failed to store tuples: %s", e, exc_info=True)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
@@ -402,7 +424,7 @@ async def submit_reward(req: RewardRequest):
|
||||
winner_seat=req.winner_seat,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to assign reward: %s", e)
|
||||
logger.error("Failed to assign reward: %s", e, exc_info=True)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
@@ -518,16 +540,24 @@ 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():
|
||||
logits = policy_service.model(state_tensor)
|
||||
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)
|
||||
|
||||
if greedy:
|
||||
action_idx = logits.argmax(dim=-1).item()
|
||||
@@ -555,7 +585,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: %s", e)
|
||||
logger.error("Direct inference error in _infer_from_vector: %s", e, exc_info=True)
|
||||
return {"error": str(e), "inference_ms": round(elapsed_ms, 2)}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,31 @@ 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.
|
||||
|
||||
@@ -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) as f:
|
||||
with open(p, encoding="utf-8") 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") as jf:
|
||||
with open(args.json_out, "w", encoding="utf-8") as jf:
|
||||
json.dump(out, jf, indent=2)
|
||||
print(f"Comparison JSON written to: {args.json_out}")
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,124 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,88 @@
|
||||
"""How well does a decision model classify a card's role from its Oracle text, zero-shot?
|
||||
|
||||
DECISION_URL=http://laya:8080 python scripts/farmhouse_laya_roles_eval.py [bulk_db] [per_role=25] [model]
|
||||
|
||||
Builds a sample from the local Scryfall bulk database using plain text patterns that give a card exactly ONE role (unambiguous cards
|
||||
only; many real cards have several roles, so this is a floor on how hard the task is, not a full test), classifies them in batches
|
||||
through farmhouse_laya.classify_roles, and prints accuracy, a confusion table and the time per card. Laya's output here would be
|
||||
evidence with provenance for ITI / Tagger-style role data, never a rules fact; this tells you whether that evidence is worth having.
|
||||
"""
|
||||
import collections
|
||||
import importlib.util
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
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"))
|
||||
L = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(L)
|
||||
|
||||
PATTERNS = {
|
||||
"removal": re.compile(r"\b(destroy|exile) target (creature|permanent|nonland permanent|artifact|enchantment|planeswalker)", re.I),
|
||||
"board_wipe": re.compile(r"\b(destroy|exile) all (creatures|nonland permanents|permanents)|each creature (gets|is dealt)", re.I),
|
||||
"ramp": re.compile(r"search your library for (a|up to \w+) basic land|\badd \{[WUBRGC]\}", re.I),
|
||||
"card_draw": re.compile(r"\bdraw (a|two|three|\w+) cards?\b", re.I),
|
||||
"counterspell": re.compile(r"\bcounter target (spell|noncreature spell|creature spell|activated or triggered ability)", re.I),
|
||||
"tutor": re.compile(r"search your library for (a|an|up to \w+) (?!basic land)(card|creature|artifact|instant|sorcery|enchantment)", re.I),
|
||||
}
|
||||
|
||||
|
||||
def sample(db, per_role, seed=193):
|
||||
conn = sqlite3.connect(db)
|
||||
rows = conn.execute("SELECT name, type_line, oracle_text FROM cards WHERE oracle_text != '' AND type_line NOT LIKE '%Land%'").fetchall()
|
||||
random.Random(seed).shuffle(rows)
|
||||
out, taken, seen = [], collections.Counter(), set()
|
||||
for name, type_line, oracle in rows:
|
||||
if name in seen or len(oracle) > 700:
|
||||
continue
|
||||
hits = [r for r, p in PATTERNS.items() if p.search(oracle)]
|
||||
if len(hits) != 1 or taken[hits[0]] >= per_role:
|
||||
continue
|
||||
seen.add(name)
|
||||
taken[hits[0]] += 1
|
||||
out.append({"name": name, "type_line": type_line, "oracle": oracle, "truth": hits[0]})
|
||||
if all(taken[r] >= per_role for r in PATTERNS):
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
db = sys.argv[1] if len(sys.argv) > 1 else os.path.join("data", "scryfall_bulk.db")
|
||||
per_role = int(sys.argv[2]) if len(sys.argv) > 2 else 25
|
||||
if len(sys.argv) > 3:
|
||||
os.environ["DECISION_MODEL"] = sys.argv[3]
|
||||
if not os.environ.get("DECISION_URL"):
|
||||
print("set DECISION_URL")
|
||||
return 1
|
||||
cards = sample(db, per_role)
|
||||
t0 = time.time()
|
||||
got = L.classify_roles(cards)
|
||||
dt = time.time() - t0
|
||||
answered = [(c["truth"], got[c["name"]]["role"], got[c["name"]]["confidence"]) for c in cards if c["name"] in got]
|
||||
print("%d cards sampled (%s); %d classified in %.1f s (%.2f s per card)" % (
|
||||
len(cards), dict(collections.Counter(c["truth"] for c in cards)), len(answered), dt, dt / max(len(cards), 1)))
|
||||
if not answered:
|
||||
return 1
|
||||
ok = sum(t == p for t, p, _ in answered)
|
||||
print("accuracy %.1f%% (always-one-role baseline %.1f%%; there are %d roles to choose among)" % (
|
||||
100.0 * ok / len(answered), 100.0 / len(PATTERNS), len(L.ROLE_HELP)))
|
||||
for lo in (0.5, 0.7, 0.85):
|
||||
sel = [(t, p) for t, p, c in answered if c >= lo]
|
||||
if sel:
|
||||
print(" when >= %.2f sure: %d cards (%.0f%%), accuracy %.1f%%" % (lo, len(sel), 100.0 * len(sel) / len(answered),
|
||||
100.0 * sum(t == p for t, p in sel) / len(sel)))
|
||||
conf = collections.defaultdict(collections.Counter)
|
||||
for t, p, _ in answered:
|
||||
conf[t][p] += 1
|
||||
print("truth -> what it said:")
|
||||
for t in PATTERNS:
|
||||
print(" %-13s %s" % (t, dict(conf[t].most_common(4))))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate lan-client/precons.js from precon-decks/precon-index.json
|
||||
* Usage: node scripts/generate-precons-js.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const indexPath = path.join(__dirname, '..', 'precon-decks', 'precon-index.json');
|
||||
const outputPath = path.join(__dirname, '..', 'lan-client', 'precons.js');
|
||||
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
||||
|
||||
let js = '// Auto-generated from precon-decks/precon-index.json\n';
|
||||
js += '// Run: node scripts/generate-precons-js.js to regenerate\n';
|
||||
js += 'var PRECON_DATA = [\n';
|
||||
|
||||
index.forEach(function(p, i) {
|
||||
const entry = {
|
||||
id: (p.fileName || p.name || '').replace('.dck', ''),
|
||||
name: p.name || '',
|
||||
commander: p.commander || (p.commanders && p.commanders[0]) || '',
|
||||
set: p.set || '',
|
||||
fileName: p.fileName || '',
|
||||
colors: p.colors || [],
|
||||
year: p.year || 0,
|
||||
};
|
||||
js += ' ' + JSON.stringify(entry);
|
||||
js += (i < index.length - 1) ? ',\n' : '\n';
|
||||
});
|
||||
|
||||
js += '];\n';
|
||||
|
||||
fs.writeFileSync(outputPath, js);
|
||||
console.log(`Generated ${outputPath} with ${index.length} precon entries`);
|
||||
@@ -21,6 +21,7 @@ _BUILTIN_DECK_DIRS = [
|
||||
_REPO_ROOT / "precon-decks",
|
||||
_REPO_ROOT / "sample-decks",
|
||||
_REPO_ROOT / "imported-decks",
|
||||
_REPO_ROOT / "user-decks",
|
||||
]
|
||||
|
||||
|
||||
@@ -352,7 +353,23 @@ def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
) cr ON cr.name = dc.card_name
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
result = [_row_to_dict(r) for r in rows]
|
||||
|
||||
# Enrich cards missing type_line from Forge card cache
|
||||
try:
|
||||
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card
|
||||
for card in result:
|
||||
if not card.get("type_line"):
|
||||
forge = lookup_forge_card(card.get("card_name", ""))
|
||||
if forge:
|
||||
card["type_line"] = forge.type_line
|
||||
card["oracle_text"] = forge.oracle_text
|
||||
card["cmc"] = forge.cmc
|
||||
card["mana_cost"] = forge.mana_cost
|
||||
except ImportError:
|
||||
pass # Forge card loader not available
|
||||
|
||||
return result
|
||||
|
||||
# -- 2. .dck file fallback --
|
||||
dck_path = _find_dck_file(deck_name)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Deck LINKS for every deck import (copied into the app as services/farmhouse_deck_links.py by the Dockerfile).
|
||||
|
||||
Upstream could import from an Archidekt or EDHREC link on the simulator page only; the Deck Builder import took pasted text and
|
||||
nothing else, and Moxfield (where most people keep decks) was not accepted anywhere. This reads a Moxfield or Archidekt deck link and
|
||||
returns what the existing importers already understand: a profile for the simulator import, or plain "[Commander] / [Main]" text for
|
||||
the Deck Builder's two imports (so a link pasted into the same box works, and the card-by-card Scryfall matching is unchanged).
|
||||
|
||||
Moxfield has no official public API; api2.moxfield.com answers server-side requests for PUBLIC decks (tested from the cluster
|
||||
2026-10-26: HTTP 200, a named User-Agent is enough). A private or deleted deck comes back 401/403/404, and the message says so and
|
||||
says what to do instead (Moxfield: Export > Copy as text, then paste that). Nothing here logs in to anything.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
UA = "CommanderAILab/3.0 (self-hosted)"
|
||||
MOXFIELD_API = "https://api2.moxfield.com/v2/decks/all/"
|
||||
|
||||
_MOX = re.compile(r"^https?://(?:www\.)?moxfield\.com/decks/([A-Za-z0-9_-]+)(?:[/?#].*)?$", re.I)
|
||||
_ARC = re.compile(r"^https?://(?:www\.)?archidekt\.com/decks/(\d+)(?:[/?#].*)?$", re.I)
|
||||
|
||||
UNSUPPORTED = ("That link is not one I can read. Moxfield and Archidekt deck links work; for anything else, paste the decklist as text "
|
||||
"(Moxfield: Export > Copy as text).")
|
||||
|
||||
|
||||
def link_kind(text):
|
||||
"""(kind, id, url) when `text` is nothing but one http(s) link, else None. kind: moxfield | archidekt | unsupported."""
|
||||
t = (text or "").strip()
|
||||
if not t or len(t.split()) != 1 or not re.match(r"^https?://", t, re.I):
|
||||
return None
|
||||
m = _MOX.match(t)
|
||||
if m:
|
||||
return "moxfield", m.group(1), t
|
||||
m = _ARC.match(t)
|
||||
if m:
|
||||
return "archidekt", m.group(1), t
|
||||
return "unsupported", None, t
|
||||
|
||||
|
||||
def _get_json(url):
|
||||
with urlopen(Request(url, headers={"User-Agent": UA, "Accept": "application/json"}), timeout=25) as resp:
|
||||
return json.loads(resp.read().decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def fetch_moxfield(deck_id, attempts=4):
|
||||
last = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return _get_json(MOXFIELD_API + deck_id)
|
||||
except HTTPError as exc:
|
||||
if exc.code in (401, 403, 404):
|
||||
raise ValueError("Moxfield would not share that deck (it is private, unlisted for sharing, or the link is wrong). "
|
||||
"Open it in Moxfield, choose Export > Copy as text, and paste that here instead.")
|
||||
if exc.code in (429, 500, 502, 503, 504):
|
||||
last = exc
|
||||
try:
|
||||
wait = float(exc.headers.get("Retry-After") or 0)
|
||||
except (TypeError, ValueError):
|
||||
wait = 0.0
|
||||
time.sleep(max(wait, 2.0) + i * 2.0)
|
||||
continue
|
||||
raise ValueError("Moxfield answered HTTP %s for that deck." % exc.code)
|
||||
except (ValueError, OSError) as exc:
|
||||
raise ValueError("Could not read the deck from Moxfield (%s)." % str(exc)[:80])
|
||||
raise ValueError("Moxfield is rate-limiting this server right now (HTTP %s). Try again in a minute." % (last.code if last else "429"))
|
||||
|
||||
|
||||
def _board(deck, key):
|
||||
out = {}
|
||||
for name, entry in (deck.get(key) or {}).items():
|
||||
card = (entry or {}).get("card") or {}
|
||||
label = card.get("name") or name
|
||||
try:
|
||||
qty = int((entry or {}).get("quantity") or 1)
|
||||
except (TypeError, ValueError):
|
||||
qty = 1
|
||||
out[label] = out.get(label, 0) + max(qty, 1)
|
||||
return out
|
||||
|
||||
|
||||
def moxfield_profile(deck, url):
|
||||
"""The same profile dict the Archidekt importer builds. Sideboard, maybeboard and companions are left out (the 99 + commander)."""
|
||||
commanders, main = _board(deck, "commanders"), _board(deck, "mainboard")
|
||||
profile = {
|
||||
"name": deck.get("name") or "Moxfield deck",
|
||||
"commander": next(iter(commanders), None),
|
||||
"source": "Moxfield",
|
||||
"sourceUrl": deck.get("publicUrl") or url,
|
||||
"commanders": commanders,
|
||||
"mainboard": main,
|
||||
"colorIdentity": list(deck.get("colorIdentity") or []),
|
||||
"totalCards": sum(commanders.values()) + sum(main.values()),
|
||||
}
|
||||
if profile["commander"] and not deck.get("name"):
|
||||
profile["name"] = "%s — Moxfield" % profile["commander"]
|
||||
return profile
|
||||
|
||||
|
||||
def fetch_profile(url):
|
||||
kind = link_kind(url)
|
||||
if not kind or kind[0] == "unsupported":
|
||||
raise ValueError(UNSUPPORTED)
|
||||
if kind[0] == "moxfield":
|
||||
profile = moxfield_profile(fetch_moxfield(kind[1]), kind[2])
|
||||
else:
|
||||
from services.import_helpers import _fetch_archidekt_deck # the app's own Archidekt reader
|
||||
try:
|
||||
profile = _fetch_archidekt_deck(kind[1])
|
||||
except HTTPError as exc:
|
||||
raise ValueError("Archidekt would not share that deck (HTTP %s). Is it public?" % exc.code)
|
||||
if not profile["commanders"] and not profile["mainboard"]:
|
||||
raise ValueError("That deck has no cards in it (or no main deck I can read).")
|
||||
return profile
|
||||
|
||||
|
||||
def profile_to_text(profile):
|
||||
"""Plain text in the Deck Builder's own format: [Commander] then [Main], one 'qty name' per line."""
|
||||
lines = ["[Commander]"]
|
||||
lines += ["%d %s" % (q, n) for n, q in profile["commanders"].items()]
|
||||
lines += ["", "[Main]"]
|
||||
lines += ["%d %s" % (q, n) for n, q in profile["mainboard"].items()]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def local_card(name, conn=None):
|
||||
"""{'id', 'name'} from the local Scryfall bulk database (113k cards, no network), or None. Matches the exact name, or the front
|
||||
face of a double-faced / split card ('Fire' finds 'Fire // Ice'). The Deck Builder's importers only use these two fields, and
|
||||
looking each card up on Scryfall's website instead hit its rate limit (HTTP 429) after a few dozen cards of a 100-card list,
|
||||
which the app reported as 'not found'."""
|
||||
try:
|
||||
if conn is None:
|
||||
from services.scryfall_bulk import get_bulk_db
|
||||
conn = get_bulk_db()
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return None
|
||||
like = n.replace("!", "!!").replace("%", "!%").replace("_", "!_") + " // %"
|
||||
row = conn.execute("SELECT scryfall_id, name FROM cards WHERE LOWER(name) = LOWER(?) OR LOWER(name) LIKE LOWER(?) ESCAPE '!' "
|
||||
"ORDER BY (LOWER(name) = LOWER(?)) DESC, edhrec_rank ASC LIMIT 1", (n, like, n)).fetchone()
|
||||
return {"id": row[0], "name": row[1]} if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def expand(text):
|
||||
"""(text, deck name). A lone Moxfield/Archidekt link becomes the decklist text; anything else comes back unchanged.
|
||||
A lone link we cannot read raises ValueError with a message meant for the person."""
|
||||
kind = link_kind(text)
|
||||
if kind is None:
|
||||
return text, None
|
||||
profile = fetch_profile(kind[2])
|
||||
return profile_to_text(profile), profile["name"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Card data for the simulators (copied into the app as services/farmhouse_sim.py by the Dockerfile).
|
||||
|
||||
Found 2026-10-26 from a real run: every card of every saved deck 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 here; the Forge card cache that was meant to fill the gap
|
||||
is not installed). The simulator could not tell a land from a creature, so a whole 8-game batch cast 0 spells, dealt 0 damage and lost
|
||||
every game. The batch loader also did not repeat cards by quantity, so a 100-card deck loaded as 66-84 cards with ONE Island.
|
||||
|
||||
fill_card_data fills the gaps from the local 113k-card Scryfall bulk database; expand_quantities repeats a row `quantity` times.
|
||||
"""
|
||||
|
||||
|
||||
def _name(card):
|
||||
return (card.get("card_name") or card.get("name") or "").strip()
|
||||
|
||||
|
||||
def _lookup(conn, name, cache):
|
||||
key = name.lower()
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
like = name.replace("!", "!!").replace("%", "!%").replace("_", "!_") + " // %"
|
||||
row = conn.execute(
|
||||
"SELECT type_line, oracle_text, mana_cost, cmc, power, toughness, keywords, color_identity FROM cards "
|
||||
"WHERE LOWER(name) = LOWER(?) OR LOWER(name) LIKE LOWER(?) ESCAPE '!' "
|
||||
"ORDER BY (LOWER(name) = LOWER(?)) DESC, edhrec_rank ASC LIMIT 1", (name, like, name)).fetchone()
|
||||
cache[key] = row
|
||||
return row
|
||||
|
||||
|
||||
def fill_card_data(cards, conn=None):
|
||||
"""Fill type_line / oracle_text / mana_cost / cmc / power / toughness / keywords / color_identity where a card dict lacks them.
|
||||
Never overwrites a value that is already there. Returns the same list."""
|
||||
try:
|
||||
if conn is None:
|
||||
from services.scryfall_bulk import get_bulk_db
|
||||
conn = get_bulk_db()
|
||||
cache = {}
|
||||
for card in cards:
|
||||
n = _name(card)
|
||||
if not n or card.get("type_line"):
|
||||
continue
|
||||
row = _lookup(conn, n, cache)
|
||||
if not row:
|
||||
continue
|
||||
for field, value in zip(("type_line", "oracle_text", "mana_cost", "cmc", "power", "toughness", "keywords", "color_identity"), row):
|
||||
if value not in (None, "") and not card.get(field):
|
||||
card[field] = value
|
||||
except Exception:
|
||||
pass
|
||||
return cards
|
||||
|
||||
|
||||
def expand_quantities(cards):
|
||||
"""One dict per physical card: a row with quantity 23 becomes 23 rows (each quantity 1)."""
|
||||
out = []
|
||||
for card in cards:
|
||||
try:
|
||||
q = int(card.get("quantity") or 1)
|
||||
except (TypeError, ValueError):
|
||||
q = 1
|
||||
for _ in range(max(q, 1)):
|
||||
copy = dict(card)
|
||||
copy["quantity"] = 1
|
||||
out.append(copy)
|
||||
return out
|
||||
|
||||
|
||||
def prepare(cards):
|
||||
"""Rows as the batch loader returns them -> one filled-in dict per physical card."""
|
||||
return fill_card_data(expand_quantities(cards))
|
||||
@@ -449,7 +449,7 @@ def _get_deepseek_brain():
|
||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
||||
cfg = DeepSeekConfig(
|
||||
api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"),
|
||||
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
|
||||
model=os.environ.get("BRAIN_MODEL", "qwen2.5:7b"),
|
||||
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
|
||||
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
|
||||
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
|
||||
@@ -499,7 +499,7 @@ def _run_deepseek_batch_thread(
|
||||
for fname in sorted(os.listdir(results_dir), reverse=True):
|
||||
if fname.startswith('ml-decision-') and fname.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(results_dir, fname), 'r') as rf:
|
||||
with open(os.path.join(results_dir, fname), 'r', encoding='utf-8') as rf:
|
||||
past = json.loads(rf.read())
|
||||
for dd in past.get('decks', []):
|
||||
dname = dd.get('deckName', '')
|
||||
@@ -515,6 +515,8 @@ def _run_deepseek_batch_thread(
|
||||
deck_meta = {}
|
||||
for dn in deck_names:
|
||||
raw_cards = _load_deck_cards_by_name(dn)
|
||||
from services.farmhouse_sim import prepare as _fh_prepare # FARMHOUSE_SIM_DATA
|
||||
raw_cards = _fh_prepare(raw_cards or [])
|
||||
if not raw_cards:
|
||||
state.log_lines.append(f'[DeepSeek Batch] WARNING: Could not load deck "{dn}", skipping.')
|
||||
continue
|
||||
|
||||
@@ -97,6 +97,9 @@ def _import_from_url(url: str) -> dict:
|
||||
if not m:
|
||||
raise ValueError("Could not parse Archidekt deck ID from URL")
|
||||
return _fetch_archidekt_deck(m.group(1))
|
||||
if "moxfield.com/decks/" in url: # FARMHOUSE_DECK_LINKS_V1
|
||||
from services.farmhouse_deck_links import fetch_profile as _fh_fetch_profile
|
||||
return _fh_fetch_profile(url)
|
||||
if "edhrec.com/average-decks/" in url:
|
||||
slug = re.search(r"/average-decks/([^/?#]+)", url).group(1)
|
||||
return _fetch_edhrec_average(slug.replace("-", " "))
|
||||
|
||||
@@ -368,6 +368,9 @@ def build_index(force: bool = False, batch_size: int = DEFAULT_BATCH_SIZE) -> di
|
||||
def ensure_rag_store() -> None:
|
||||
"""Build the ChromaDB vector store if it is missing or stale."""
|
||||
global _rag_ready
|
||||
if os.environ.get("RAG_AUTOBUILD", "0") != "1": # FARMHOUSE_RAG_AUTOBUILD_V1
|
||||
logger.info("RAG vector store auto-build is off (RAG_AUTOBUILD is not 1).")
|
||||
return
|
||||
with _rag_lock:
|
||||
if _rag_ready:
|
||||
return
|
||||
@@ -445,7 +448,7 @@ def query_cards(
|
||||
|
||||
try:
|
||||
has_filters = bool(color_identity or type_filter or require_trigger)
|
||||
fetch_n = n_results * 4 if has_filters else n_results
|
||||
fetch_n = n_results * 12 # FARMHOUSE_RAG_DEDUPE (one index entry per printing)
|
||||
query_kwargs = dict(
|
||||
query_texts=[query],
|
||||
n_results=min(fetch_n, 200),
|
||||
@@ -462,6 +465,7 @@ def query_cards(
|
||||
return []
|
||||
|
||||
cards: list[dict] = []
|
||||
_fh_seen = set() # FARMHOUSE_RAG_DEDUPE
|
||||
ids = results["ids"][0]
|
||||
docs = results["documents"][0] if results.get("documents") else [""] * len(ids)
|
||||
metas = results["metadatas"][0] if results.get("metadatas") else [{}] * len(ids)
|
||||
@@ -490,6 +494,10 @@ def query_cards(
|
||||
continue
|
||||
|
||||
# Parse oracle from document — handle both old pipe format and new NL format
|
||||
_fh_name = meta.get("name", "")
|
||||
if _fh_name in _fh_seen:
|
||||
continue
|
||||
_fh_seen.add(_fh_name)
|
||||
parts = doc.split(" | ", 3)
|
||||
oracle = parts[-1] if len(parts) > 1 else doc
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ def _get_bulk_download_url() -> str:
|
||||
meta = json.loads(resp.read())
|
||||
for entry in meta.get("data", []):
|
||||
if entry.get("type") == "default_cards":
|
||||
return entry["download_uri"]
|
||||
return entry.get("jsonl_download_uri") or entry["download_uri"] # FARMHOUSE_SCRYFALL_URL_V1
|
||||
raise RuntimeError("Could not find default_cards entry in Scryfall bulk-data manifest")
|
||||
|
||||
|
||||
@@ -122,12 +122,18 @@ def _download_and_index(conn: sqlite3.Connection) -> int:
|
||||
url = _get_bulk_download_url()
|
||||
logger.info("Downloading Scryfall default_cards bulk export (~100 MB)...")
|
||||
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
with urlopen(req, timeout=300) as resp:
|
||||
raw_bytes = resp.read()
|
||||
req = Request(url, headers=_API_HEADERS) # FARMHOUSE_SCRYFALL_JSONL_V1
|
||||
_resp = urlopen(req, timeout=300)
|
||||
|
||||
cards_json: list[dict] = json.loads(raw_bytes)
|
||||
logger.info(f"Received {len(cards_json)} card objects from Scryfall.")
|
||||
def _cards_stream():
|
||||
import gzip
|
||||
stream = gzip.GzipFile(fileobj=_resp) if url.split("?")[0].endswith(".gz") else _resp
|
||||
for _line in stream:
|
||||
_line = _line.strip().rstrip(b",")
|
||||
if _line and _line not in (b"[", b"]"):
|
||||
yield json.loads(_line)
|
||||
|
||||
cards_json = _cards_stream()
|
||||
|
||||
# Wipe existing data for a clean rebuild
|
||||
conn.execute("DELETE FROM cards")
|
||||
|
||||
@@ -26,7 +26,7 @@ class DeckBuilderAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
db_conn_factory: Callable[[], sqlite3.Connection] = None,
|
||||
model: str = "gpt-oss:20b",
|
||||
model: str = "claude-opus-4-20250514",
|
||||
):
|
||||
self.db_conn_factory = db_conn_factory
|
||||
self.model = model
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Ollama gpt-oss:20b client for the Commander AI Deck Builder.
|
||||
Ollama client for the Commander AI Deck Builder.
|
||||
|
||||
Uses the OpenAI-compatible API exposed by Ollama at localhost:11434.
|
||||
Provides 6 core functions:
|
||||
@@ -23,8 +23,9 @@ from openai import OpenAI
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ollama exposes an OpenAI-compatible endpoint
|
||||
DEFAULT_BASE_URL = "http://localhost:11434/v1"
|
||||
DEFAULT_MODEL = "qwen2.5:7b"
|
||||
import os as _farmhouse_os # FARMHOUSE_LLM_ENV_V1
|
||||
DEFAULT_BASE_URL = _farmhouse_os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||
DEFAULT_MODEL = _farmhouse_os.environ.get("LLM_MODEL", "qwen2.5:7b")
|
||||
|
||||
|
||||
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
|
||||
@@ -118,6 +119,13 @@ def chat(
|
||||
if json_mode:
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
# FARMHOUSE_LLM_KNOBS_V1
|
||||
_fh_effort = _farmhouse_os.environ.get("LLM_REASONING", "").strip()
|
||||
if _fh_effort:
|
||||
kwargs["extra_body"] = {"reasoning_effort": _fh_effort}
|
||||
_fh_cap = int(_farmhouse_os.environ.get("LLM_MAX_TOKENS", "0") or 0)
|
||||
if _fh_cap:
|
||||
kwargs["max_tokens"] = min(kwargs["max_tokens"], _fh_cap)
|
||||
response = client.chat.completions.create(**kwargs)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
|
||||
@@ -16,8 +16,26 @@ import requests
|
||||
from ..core.models import CardEntry
|
||||
|
||||
BASE_URL = "https://api.scryfall.com"
|
||||
_SF_HEADERS = {"User-Agent": "CommanderAILab/3.0 (self-hosted)", "Accept": "application/json"} # FARMHOUSE_SCRYFALL_HEADERS_V1
|
||||
|
||||
|
||||
def _fh_request(method, url, **kw):
|
||||
"""requests.<method> that backs off on Scryfall's 429 / 5xx, honouring Retry-After (a build makes hundreds of
|
||||
calls; upstream had no retry, so one 429 anywhere ended it). Gives up after 5 tries and returns the last response."""
|
||||
import time as _t
|
||||
resp = None
|
||||
for attempt in range(5):
|
||||
resp = getattr(requests, method)(url, **kw)
|
||||
if resp.status_code not in (429, 500, 502, 503, 504):
|
||||
return resp
|
||||
try:
|
||||
wait = float(resp.headers.get("Retry-After", "") or 0)
|
||||
except ValueError:
|
||||
wait = 0.0
|
||||
_t.sleep(max(wait, 2.0) + attempt * 2.0)
|
||||
return resp
|
||||
_last_request_time: float = 0.0
|
||||
REQUEST_DELAY = 0.1 # 100ms between requests (Scryfall asks for 50-100ms)
|
||||
REQUEST_DELAY = 0.25 # 250ms: upstream used 100ms (10/s, Scryfall's ceiling) and drew 429s when builds ran back to back
|
||||
|
||||
|
||||
def _throttle() -> None:
|
||||
@@ -32,7 +50,7 @@ def _throttle() -> None:
|
||||
def _get(endpoint: str, params: Optional[Dict] = None) -> Dict:
|
||||
"""Make a throttled GET request to Scryfall."""
|
||||
_throttle()
|
||||
resp = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=15)
|
||||
resp = _fh_request("get", f"{BASE_URL}{endpoint}", params=params, headers=_SF_HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
@@ -113,7 +131,7 @@ def search_cards_by_identity(
|
||||
if not next_url:
|
||||
break
|
||||
_throttle()
|
||||
resp = requests.get(next_url, timeout=15)
|
||||
resp = _fh_request("get", next_url, headers=_SF_HEADERS, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
@@ -168,9 +186,9 @@ def get_cards_by_names(names: List[str]) -> List[CardEntry]:
|
||||
batch = names[i : i + 75]
|
||||
identifiers = [{"name": n} for n in batch]
|
||||
_throttle()
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/cards/collection",
|
||||
json={"identifiers": identifiers},
|
||||
resp = _fh_request(
|
||||
"post", f"{BASE_URL}/cards/collection",
|
||||
json={"identifiers": identifiers}, headers=_SF_HEADERS,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
@@ -87,8 +87,8 @@ def cli() -> None:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
default="gpt-oss:20b",
|
||||
help="Ollama model name (default: gpt-oss:20b)",
|
||||
default="claude-opus-4-20250514",
|
||||
help="AI model name (default: claude-opus-4-20250514)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
|
||||
@@ -119,6 +119,9 @@ def validate_card_names(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
from .farmhouse_fit import fit_deck as _farmhouse_fit_deck # FARMHOUSE_FIT_V1
|
||||
|
||||
|
||||
def build_deck(request: BuildRequest) -> BuildResult:
|
||||
"""
|
||||
Execute the full 7-step deck build pipeline.
|
||||
@@ -306,6 +309,7 @@ def build_deck(request: BuildRequest) -> BuildResult:
|
||||
logger.warning(f"Ratio enforcement returned {total} cards")
|
||||
|
||||
# ── Step 7: Assemble final deck ────────────────────────────────────────
|
||||
adjusted = _farmhouse_fit_deck(adjusted, target_ratios, commander_ci, warnings) # FARMHOUSE_FIT_V1b
|
||||
logger.info("Step 7: Assembling final deck")
|
||||
|
||||
# Build a globally-deduplicated name list. enforce_deck_ratios can
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Deterministic guards for the deck build, copied into the app by the Dockerfile (pipeline/farmhouse_fit.py).
|
||||
|
||||
Upstream's step 6 asks the LLM to trim the candidate pool to 99 cards by category and step 5 promises a colour-identity filter that
|
||||
is not in the code. With a small local model that gave decks with an off-colour card (a red card in a four-colour deck) and, with
|
||||
a larger one, 359 cards instead of 99; either made the app reject its own result ("Value error ... outside commander identity" /
|
||||
"Deck must contain exactly 99 cards"). This does both jobs in plain code, using the Scryfall card database the app already keeps
|
||||
(services.scryfall_bulk, 100,000+ cards): drop every card outside the commander's colour identity, drop duplicates, keep each
|
||||
category to its target count (candidates arrive best-first: EDHREC, then Scryfall, then the model), and never exceed 99.
|
||||
Anything the database does not know is kept, not dropped. The existing back-fill adds basic lands if the total is short.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _identity_checker(allowed):
|
||||
try:
|
||||
from services.scryfall_bulk import get_bulk_db
|
||||
conn = get_bulk_db()
|
||||
except Exception as exc: # no database: do not guess, keep everything
|
||||
logger.warning("farmhouse_fit: Scryfall card database unavailable (%s); colour identity not checked", exc)
|
||||
return lambda name: True
|
||||
|
||||
def ok(name):
|
||||
try:
|
||||
row = conn.execute("SELECT color_identity FROM cards WHERE name = ? OR name LIKE ? LIMIT 1",
|
||||
(name, name + " // %")).fetchone()
|
||||
if not row:
|
||||
return True
|
||||
return set(json.loads(row[0] or "[]")) <= allowed
|
||||
except Exception:
|
||||
return True
|
||||
return ok
|
||||
|
||||
|
||||
def fit_deck(adjusted, target_ratios, commander_ci, warnings, total=99):
|
||||
"""Return {category: [names]} that is colour-legal, duplicate-free, within each category's target, and at most `total` cards."""
|
||||
allowed = set(commander_ci or [])
|
||||
ok = _identity_checker(allowed)
|
||||
out, seen, off_colour = {}, set(), []
|
||||
for cat, names in adjusted.items():
|
||||
keep = []
|
||||
for name in names:
|
||||
if name in seen:
|
||||
continue
|
||||
if not ok(name):
|
||||
off_colour.append(name)
|
||||
continue
|
||||
seen.add(name)
|
||||
keep.append(name)
|
||||
limit = target_ratios.get(cat)
|
||||
out[cat] = keep[:limit] if isinstance(limit, int) and limit >= 0 else keep
|
||||
if off_colour:
|
||||
warnings.append("Dropped %d card(s) outside the commander's colour identity: %s" % (len(off_colour), ", ".join(off_colour[:8])))
|
||||
count = sum(len(v) for v in out.values())
|
||||
if count > total:
|
||||
# Over: trim from the tail of the largest category (the model's late picks), one card at a time.
|
||||
excess = count - total
|
||||
while excess > 0:
|
||||
cat = max(out, key=lambda c: len(out[c]))
|
||||
if not out[cat]:
|
||||
break
|
||||
out[cat].pop()
|
||||
excess -= 1
|
||||
warnings.append("Trimmed the candidate list from %d to %d cards." % (count, total))
|
||||
return out
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user