Compare commits

..
Author SHA1 Message Date
rgriffithandClaude Opus 4.6 04f479dcd0 revert: undo all card size changes, restore original layout
Reverts style.css and index.html to their state before any
card sizing modifications were made.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:27:18 -04:00
rgriffithandClaude Opus 4.6 0e49f46513 fix: force hand card size with explicit px and min-height
Use hardcoded 128x180px with min-width/min-height on .hand-thumb
to prevent flex compression. Set hand zone min-height to 180px,
align-items: stretch, and overflow-x: auto so cards render at
full size and scroll horizontally if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:19:01 -04:00
rgriffithandClaude Opus 4.6 b828c474c7 fix: bump CSS cache-busting version to force reload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:15:21 -04:00
rgriffithandClaude Opus 4.6 105c8db4ab fix: double hand card size again to fill vertical space
Hand cards (--hand-card-w/h) bumped from 64x90 to 128x180
so they fill the hand zone height. Tablet breakpoint updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:09:45 -04:00
rgriffithandClaude Opus 4.6 a5a66a81f2 fix: correct card sizing — shrink zones, enlarge hand cards
Red zones (Library/Exile/Grave/Commander): halved from original
(63→32, 88→44). Blue zone (hand cards): doubled from original
(32→64, 45→90) using new --hand-card-w/--hand-card-h variables
so hand sizing is decoupled from zone sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 14:05:33 -04:00
rgriffithandClaude Opus 4.6 bce0d50e39 feat: enlarge hand cards and commander zone 200%
Double card-w/card-h CSS variables (63→126, 88→176) and hand-thumb
fixed dimensions (32→64, 45→90) so hand and commander cards render
at 200% of their original size. Tablet breakpoint updated to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:52:32 -04:00
rgriffith 99044342c2 fix: remove stray } after showManaPool stub (line 1856) 2026-04-04 13:33:37 -04:00
rgriffith d5fd648139 fix: update tracked paths — files moved from root to lan-client/ 2026-04-03 23:31:36 -04:00
rgriffith 022f89478d fix: restore ui.js from placeholder corruption + Phase 1 patches 2026-04-03 23:25:01 -04:00
KoalaTrapLord 336f913d4b feat(phase-1): no-op mana pool display functions in ui.js
- renderManaPool() -> stub (floating #mana-pool-display removed from UI)
- showManaPool() -> stub
- Remove 'Mana Auto-Clear' row from renderSettingsPanel()

Internal mana tracking state (manaPool object, canPayManaCost,
clearManaPool, clearAllManaPools, etc.) fully preserved in engine.js.
createInitialState() already had energy/experience/rad/emblems.
setupManaPoolDrag() in engine.js already a no-op stub.
index.html and style.css require no changes.
2026-04-03 18:54:22 -04:00
KoalaTrapLord 661847a4d8 feat(phase-1): no-op mana pool display functions in ui.js
- renderManaPool() → stub comment (floating #mana-pool-display removed)
- showManaPool() → stub comment
- Remove 'Mana Auto-Clear' row from renderSettingsPanel()

Internal mana tracking state (manaPool object, canPayManaCost,
clearManaPool, etc.) fully preserved in engine.js.
createInitialState() already had energy/experience/rad/emblems.
setupManaPoolDrag() in engine.js already stubbed.
index.html and style.css require no changes.

Closes #190 Phase 1
2026-04-03 18:51:15 -04:00
KoalaTrapLord e710fd5e03 placeholder 2026-04-03 18:50:51 -04:00
KoalaTrapLord 85af163123 Wire Forge IPC into POST /api/game/start via run_batch_subprocess + --policy flag 2026-04-03 17:57:35 -04:00
KoalaTrapLord f2e87feb4e Fix default port: 8000 -> 8080 2026-04-03 17:54:15 -04:00
KoalaTrapLord 948f8e0f8f Fix PS1 parse errors: escape apostrophes, fix string terminators, fix scriptblock braces 2026-04-03 17:52:08 -04:00
KoalaTrapLord 84d56bf901 Add Test-PolicyServer.ps1 — smoke-test health + decide endpoint from PowerShell 2026-04-03 17:50:04 -04:00
KoalaTrapLord 74d24d606c fix: wire PolicyClient into real Forge IPC path via Priority interception
Forge's sim mode does not honour --policy/--policyServer CLI flags, so
the Python brain was never called during live games. AI only played lands
because Forge ran its built-in AI for every decision.

Fix: when parseForgeLine() detects a Priority event for an AI seat, the
session now immediately calls policyClient.decide() with the current
state snapshot and POSTs the result to /api/policy/override before Forge
reads the next line. This mirrors what handleHumanTurn() already does
for seat 0. The synthetic fallback path is unchanged.
2026-04-03 17:42:01 -04:00
KoalaTrapLord 98e1092ebc feat: wire DecisionExporter into Forge pipeline + fix reward call signatures
- Add _export_forge_episode() adapter: translates ForgeEpisode transitions
  back into DecisionExporter.record_step() calls so Forge-sourced wins flow
  into the distillation JSONL pipeline (closes Phase 1 for real games)
- ppo_consumer() now accepts an optional `exporter: DecisionExporter` arg;
  begin_episode / record_step / end_episode are called around each episode
  as it is dequeued
- run_forge_pipeline() forwards the exporter kwarg down to ppo_consumer and
  calls exporter.flush() after the pipeline completes, returning the output
  path in the stats dict under "exporter_file"
- Fix snapshots_to_episode(): compute_terminal_reward() was called with
  non-existent kwargs `turns=` and `life_remaining=`; compute_intermediate_reward()
  was called with non-existent kwargs `life_delta=`, `cards_drawn=`,
  `creatures_played=`.  Both now use the correct signatures from reward.py
  (prev_state/curr_state/action_taken for intermediate; won/draw/timeout for
  terminal).  Forge snapshots carry `players` arrays so we reconstruct minimal
  prev/curr dicts for the intermediate path.

Ref: docs/CLOSED-LOOP-DISTILLATION.md Phase 1
2026-04-03 17:09:05 -04:00
KoalaTrapLord 9338ac881b fix(labeler): infer cast sub-type from hand contents when action.card absent
The Forge DecisionExporter writes action={"type":"cast"} without a card
field — action.get("card") and action.get("raw") always returned "",
causing every cast to fall through to the CAST_CREATURE default.

Fix: when card_name is empty, scan the active player's hand against
the removal / draw / ramp / commander name databases and pick the
highest-priority match found.  Priority order mirrors cast importance:
  removal > ramp > draw > commander > creature (default)

A hand-level match is a reasonable proxy for the decision made:
Forge's AI picks the highest-priority action from available options,
so if the hand contains a removal spell during main phase, the cast
action almost certainly represents that removal spell being played.

Also fixes print_label_distribution: was sorting by dict key (string)
instead of by index, causing the display order to be alphabetical
rather than matching the MacroAction enum order — making cast_creature
appear at position 0 in the sorted output even when it had counts.
Now prints in descending count order with correct enum-keyed lookup.
2026-04-03 16:55:59 -04:00
KoalaTrapLord c3ff9f8888 feat(data): add DeepSeek ds- source with 4x default upweight
_discover_jsonl_files now recognises ml-decisions-ds-*.jsonl as its own
"ds" source key instead of falling through to legacy/forge.

DatasetConfig.source_weights gains ds=4.0 as a default so the 8 ds files
(1775 records, ~5.8% raw) are oversampled to ~20% of effective training
tokens, matching their higher signal quality.

CLI gains --ds-weight argument (default 4.0) to override without code changes.

The training_manifest.json produced by PowerShell already encodes weight=4.0
per ds file; this change makes dataset_builder.py honour the same ratio
natively so the manifest and the builder stay in sync.
2026-04-03 16:46:57 -04:00
KoalaTrapLord e4f35a2d8b debug: log top-5 action probabilities on every decide() call
Adds a single INFO log line after each successful inference showing:
  - chosen action + confidence
  - top-5 actions sorted by probability
  - turn, phase, vector_source

This lets us diagnose the 'only plays lands' behaviour without a Python
REPL. Remove or downgrade to DEBUG once the training issue is resolved.
2026-04-03 16:08:50 -04:00
KoalaTrapLord 084742a56a docs: rewrite README — full feature coverage from 52 merged PRs 2026-04-03 20:04:32 +00:00
KoalaTrapLord 45a413837b docs: rewrite README — full feature coverage from 52 merged PRs 2026-04-03 20:04:32 +00:00
KoalaTrapLord 018852c92d fix: collapse cmdr_dmg dict to int in _encode_global before division
Forge sends cmdr_dmg as a dict keyed by opponent seat (e.g. {"0": 14,
"2": 7}). _encode_global read it raw and crashed on `dict / 21.0`.

Fix: resolve cmdr_dmg to a scalar at the read site — sum dict values if
it's a dict, cast to int if it's already a number, default to 0.
This is defensive for all call paths (live Forge IPC, JSONL training,
_build_encoder_snapshot pre-collapsed int) so no upstream change needed.
2026-04-03 15:46:36 -04:00
KoalaTrapLord 4ca21e5e29 debug: add exc_info=True + unique logger names to expose real traceback
Both 'Inference error' log lines use identical logger strings, making it
impossible to tell which except block is catching the crash. Also, broad
except blocks were swallowing the full traceback so the real crash site
and cause were hidden.

Changes:
- policy_server.py: logger renamed to 'ml.serving.policy_server';
  predict() and predict_batch() error handlers add exc_info=True
- routes/policy.py: logger renamed to 'routes.policy.decide';
  _infer_from_vector() error handler adds exc_info=True;
  decide() fallback warning adds exc_info=True

The next crash will print the full stack trace, pinpointing the exact
line and call chain causing the TypeError.
2026-04-03 15:39:54 -04:00
KoalaTrapLord 28665bf663 fix: apply _unwrap_logits guard in _infer_from_vector (routes/policy.py)
The crash `unsupported operand type(s) for /: 'dict' and 'float'` was
firing from _infer_from_vector(), which is the code path taken when
has_precomputed_vector() is True (GameSession 1.1.0+ snapshots with
a precomputed state_vector float[29]).

Unlike PolicyInferenceService.predict() — which was fixed in the
previous commit via _unwrap_logits — this function calls
policy_service.model(state_tensor) directly and immediately uses the
return value in arithmetic (logits / max(temperature, 0.01)), so any
tuple/dict return (DataParallel, torch.compile, PolicyValueNetwork
swap) causes the TypeError.

Fix: import _unwrap_logits from ml.serving.policy_server and apply it
to the raw model output in _infer_from_vector before the temperature
division and argmax calls. All three return shapes are now handled:
  - plain tensor  → passthrough (normal case, zero overhead)
  - tuple/list    → raw[0]  (PolicyValueNetwork (logits, value))
  - dict          → first recognised key or first tensor value
2026-04-03 15:16:49 -04:00
KoalaTrapLord 83d05294c8 fix: type-guard logits after model() to prevent 'dict / float' inference crash
If self.model is wrapped (DataParallel, torch.compile, or future
PolicyValueNetwork path), forward() can return a tuple (logits, value)
or even a dict.  Add an explicit unwrap immediately after the forward
call in both predict() and predict_batch() so every downstream
temperature/softmax operation always receives a plain tensor.
2026-04-03 14:55:58 -04:00
KoalaTrapLord e8f8766d59 fix: register policy routes at module scope before StaticFiles mounts
The root cause of the persistent /api/policy/health 404:
- app.mount("/", StaticFiles(...)) is called at module load time
- StaticFiles acts as a sub-app and consumes matching requests,
  returning its own 404 for unknown files rather than falling through
- register_policy_routes() was called in lifespan (runtime), AFTER
  the "/" mount was already in the route list — so the policy router
  was effectively unreachable

Fix: move route registration to module scope right after all other
app.include_router() calls and BEFORE any app.mount() calls. Only
the model load (_POLICY_SVC.load()) stays in lifespan.
2026-04-03 13:09:12 -04:00
KoalaTrapLord a69b273f5f fix: always register policy routes at startup regardless of model load state
Previously /api/policy/health returned 404 when the model wasn't loaded
because register_policy_routes() was gated behind _policy_svc.load().
The health endpoint itself already handles the unloaded state gracefully
(returns {ready: false, status: degraded}), so the router must be mounted
unconditionally so ai-bridge.js can poll it during startup.
2026-04-03 12:29:18 -04:00
KoalaTrapLord e55ac9aebf feat: add Scryfall image enrichment after .dck load — batch fetch card art for all zones 2026-04-03 11:30:30 -04:00
KoalaTrapLord d2b86ef2ed fix: wire precon-loader.js into index.html script load order 2026-04-03 11:19:37 -04:00
KoalaTrapLord 081dd66121 fix: add startGameWithDecks() and loadPreconDeckFromDck() for precon .dck loading into simulator 2026-04-03 11:17:27 -04:00
KoalaTrapLord 6d89d5bb08 fix: add PRECON_DIR env var (PRECON_DECKS_DIR was never read by lab_api.py) 2026-04-03 11:15:31 -04:00
KoalaTrapLord f29a5a31b5 feat: add /api/lab/precons and /api/lab/precons/deck endpoints
Fixes 404 errors from lan-client ai-bridge.js which calls these routes
to populate the deck selector. Routes were missing from lab.py — only
precon_router (routes/precon.py) existed but served different paths.

Also added PRECON_DECKS_DIR env var alias in lab_api.py _parse_args()
to match the .env variable name users commonly set.
2026-04-03 11:08:12 -04:00
KoalaTrapLord 370d4cd92f Merge pull request #200 from KoalaTrapLord/fix/serve-lan-client-from-backend
fix: Serve lan-client from backend at /lan (same-origin)
2026-04-03 10:48:01 -04:00
Commander AI Lab e90efe1654 fix: serve lan-client from backend at /lan on port 8080
- Mount lan-client/ as static files at /lan with html=True
- Mount precon-decks/ at /precon-decks/ for direct .dck access
- ai-bridge.js: use same-origin (empty URLs, auto-detect from window.location)
- engine.js: simplify deck fetch to use /api/lab/precons/deck endpoint + /precon-decks/ static fallback
- Eliminates cross-origin issues by serving everything from localhost:8080

Access the LAN battlefield at http://localhost:8080/lan/
2026-04-03 14:30:55 +00:00
KoalaTrapLord bf701397b0 Merge pull request #199 from KoalaTrapLord/fix/deck-loading-and-backend-detection
fix: Deck loading — wire setupDeckChoices, fetch .dck content, add precon/deck endpoint
2026-04-03 09:49:33 -04:00
Commander AI Lab 7750b43494 fix: deck loading — wire startGameWithDecks, fetch .dck content, parse [sections]
Three fixes that prevented precon decks from loading into actual gameplay:

1. ui.js: window.startGame() now calls startGameWithDecks() when
   setupDeckChoices has entries (the 3-tab selector stores choices there).
   Previously it called initGame() which looked for old deck-sel-{i}
   dropdowns that no longer exist.

2. engine.js: loadPreconForPlayer() now fetches .dck file content from
   the backend (/api/lab/precons/deck?fileName=...) when precon.decklist
   is empty. Falls back to fetching precon-decks/{file} directly.
   PRECON_DATA entries only have metadata (name, commander, set) — the
   actual card list lives in the .dck files.

3. engine.js: parseDeckList() now handles .dck bracket-style section
   headers like [Commander], [Main], [metadata]. Previously only matched
   bare text like 'COMMANDER:' without brackets. Also skips [metadata]
   lines (e.g. 'Name=Elven Empire').

4. routes/precon.py: Added GET /api/lab/precons/deck?fileName=... endpoint
   to serve raw .dck file content for the LAN client.
2026-04-03 13:45:15 +00:00
KoalaTrapLord fa58463cde Merge pull request #198 from KoalaTrapLord/fix/setup-screen-layout
fix: Setup screen overflows viewport — 2-col deck grid + scroll
2026-04-03 09:38:26 -04:00
Commander AI Lab 94b1e050d8 fix: setup screen overflows viewport — constrain height and use 2-col deck grid
- Add max-height: 92vh and overflow-y: auto to .setup-card
- Widen .setup-card to 860px to fit 2-column deck grid
- Change .deck-assignment-grid from vertical stack to 2-column grid
  so 4 player panels render in 2 rows instead of 4
- Tighten .deck-tab-content (170px) and .deck-precon-list (140px)
- Make top gradient bar sticky on scroll
- Responsive fallback to single column below 640px
2026-04-03 13:37:02 +00:00
KoalaTrapLord 8bfde0230b fix: add precons.js with 163 entries and fix backend precon UI refresh (#197)
- Generate lan-client/precons.js from precon-decks/precon-index.json so
  PRECON_DATA is always available for local/offline mode (163 precons)
- Add scripts/generate-precons-js.js for regeneration when precons update
- Fix _loadBackendPrecons() to re-render the precon tab UI when backend
  precons load successfully (was only updating status indicator text,
  not populating the dropdown)

Without precons.js, PRECON_DATA was undefined and the dropdown showed
'No precon data loaded' even though 163 .dck files exist in the repo.
2026-04-03 09:31:54 -04:00
KoalaTrapLordandClaude Opus 4.6 4214831e59 feat: LAN Battlefield App Integration with AI Pipeline (#196)
* feat: LAN Battlefield App integration with Commander-AI-Lab AI pipeline

Add the LAN Battlefield App as a browser client (lan-client/) that
connects to the Commander-AI-Lab backend for ML-powered AI opponents,
3-category deck selection, and enhanced player panels.

Phase 1 - Foundation:
- Remove floating mana pool display (keep internal tracking)
- Add energy, experience, rad, emblems fields to game state
- No-op renderManaPool(), showManaPool(), setupManaPoolDrag()

Phase 2 - Backend Connection (ai-bridge.js):
- BackendConnector: REST + WebSocket connectivity with retry/timeout
- GameStateBridge: Serializes engine.js state for /api/policy/decide
- MacroActionMapper: Translates 8 ML macro-actions to engine.js calls
- DeckSourceClient: Fetches precon/EDHREC decks from backend API

Phase 3 - Deck System:
- 3-tab deck selector (Precons / EDHREC / My Decks) replaces dropdown
- Precon grouping by set with collapsible sections and filtering
- EDHREC search integration (stub, ready for backend implementation)
- My Decks textarea import placeholder

Phase 4 - AI Integration:
- runAiTurn() priority chain: ML Pipeline > Forge > Local Heuristic
- _runMlAiTurn() async turn loop with multi-phase decision requests
- Backend session initialization on game start
- Automatic fallback to local AI on any ML pipeline error

Phase 5 - Enhanced Player Panels (player-panels.js):
- Collapsible panels with localStorage persistence
- Commander damage progress bars with warning/critical thresholds
- Poison, energy, experience, rad counter widgets
- Emblem tracking with add/remove

Phase 6 - Polish:
- Script loading order: precons > engine > ai-bridge > panels > ui
- Backend auto-detection on page load with status indicator
- Connection status display on setup screen

Closes #184, Closes #185, Closes #186, Closes #187, Closes #188, Closes #189

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: refine LAN battlefield integration with full JSDoc, proper phase IDs

- Add comprehensive JSDoc comments to all ai-bridge.js classes
- Use optional chaining for PHASES lookup in GameStateBridge
- Direct addLogEntry calls in player-panels.js (loaded after engine.js)
- Remove leftover mana pool CSS that was missed in initial pass
- Minor style.css cleanup for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-03 09:16:27 -04:00
KoalaTrapLord e2568c731c fix: auto-load .env in state.py so dotenv works without explicit load_dotenv call
When running plain `python script.py` or bare uvicorn, .env is never
loaded automatically. Added a dotenv bootstrap block at the top of
state.py that finds and loads the nearest .env file (walks up from
models/ to the project root). Uses load_dotenv with override=False so
explicitly set environment variables always win over .env values.
Gracefully skips if python-dotenv is not installed.
2026-04-03 08:41:58 -04:00
KoalaTrapLord d6863b6d7e fix: auto-populate all CFG paths from environment variables
CFG.forge_dir, precon_dir, forge_decks_dir, forge_jar, and lab_jar
were only set via CLI args. When the server starts via uvicorn directly
(no CLI), all paths stayed empty and forge_card_loader could not find
the cardsfolder, causing 0 lands in every deck.

Fix: read FORGE_DIR, FORGE_JAR, PRECON_DIR, PRECON_DECKS_DIR,
FORGE_DECKS_DIR, and LAB_JAR from environment at import time, mirroring
the existing ANTHROPIC_API_KEY / XIMILAR_API_KEY pattern.
2026-04-03 04:56:44 -04:00
KoalaTrapLord 1fc99a5d4a fix: auto-discover precon-decks/ and sample-decks/ when CFG paths unset
_find_dck_file only searched CFG.precon_dir / CFG.forge_decks_dir, both
of which are empty when the server is started without CLI args. This
caused all precon deck lookups to return 0 cards.

Fix: add _REPO_ROOT and _BUILTIN_DECK_DIRS constants, then append
precon-decks/, sample-decks/, imported-decks/ to the search list
whenever they exist on disk, regardless of whether CFG paths are set.
2026-04-03 04:50:49 -04:00
KoalaTrapLord eed2c7f126 New HTML battlefield to test features before Unity (#182) 2026-04-03 04:39:33 -04:00
KoalaTrapLord 7a40b5deb8 fix: enrich type_line/oracle from Forge cache in .dck fallback loader
_load_deck_cards_from_dck was hardcoding type_line="" for every card,
causing Card.is_land() to return False for all lands when a deck is
loaded via the .dck file path (i.e. not in the SQLite DB). This meant
the engine never recognised lands as lands, breaking mana production.

Fix: call lookup_forge_card() per card and populate type_line,
oracle_text, cmc, mana_cost, power, toughness, and keywords from the
Forge cardsfolder cache. The cache is lazy-loaded and module-level so
there is no per-game overhead after the first deck load.
2026-04-03 04:01:59 -04:00
KoalaTrapLord af8970f3a8 fix: _load_deck_cards_by_name falls back to .dck file when deck not in DB
Precon decks (Elven_Empire, Blood_Rites, etc.) are stored as .dck files
on disk but not inserted into the SQLite decks/deck_cards tables.
The DeepSeek batch engine calls _load_deck_cards_by_name() which
previously returned [] for any deck not in the DB, causing
'No decks could be loaded.' errors.

Adds _find_dck_file() and _load_deck_cards_from_dck() helpers.
_find_dck_file searches CFG.precon_dir, CFG.forge_decks_dir, and
FORGE_DIR/res/decks/commander with exact + fuzzy matching.
_load_deck_cards_by_name now tries DB first, then .dck fallback.
2026-04-02 22:59:01 -04:00
KoalaTrapLord 53fd1c9257 fix: wire RESULTS_DIR and FORGE_DIR env vars into CFG at startup 2026-04-02 14:24:28 -04:00
KoalaTrapLord 6ead75f96b fix: set FORGE_WORKING_DIR to forge-gui (requires res/ folder for sim to start) 2026-04-02 02:33:14 -04:00
KoalaTrapLord 535bdd7c70 fix: make Forge deck paths fully portable using %~dp0 and auto-generate forge.profile.properties 2026-04-02 00:59:10 -04:00
KoalaTrapLord d98e6331e9 fix: point all deck lookups to precon-decks directory
- start-lab.bat: Set FORGE_DECKS_DIR to D:\ForgeCommander\commander-ai-lab\precon-decks
- BatchRunner.java: resolveForgeDecksPath() now checks PRECON_DECKS_DIR env var
  first, then falls back to the hardcoded precon-decks path, before trying
  %APPDATA%\Forge\decks\commander. Also pass full absolute path to Forge -d flag.
- .env.example: document PRECON_DECKS_DIR variable
2026-04-01 23:10:32 -04:00
KoalaTrapLord 2b3196267f fix: raise default clock from 300ms to 5000ms to prevent turn-1 draw games 2026-04-01 22:56:29 -04:00
rgriffith 433e8bc4d7 feat: wire precon card art through local /card-image/ API with Scryfall fallback 2026-04-01 22:09:13 -04:00
KoalaTrapLord d74dd0a6bf feat: wire precon card art through local /card-image/ API with Scryfall fallback (full file) 2026-04-01 21:45:15 -04:00
KoalaTrapLord 8037802dff feat: wire precon card art through local /card-image/ API with Scryfall fallback 2026-04-01 21:45:01 -04:00
KoalaTrapLord ca6c23a881 fix: wrap shutil.copy2 in try/except for install and install-batch endpoints 2026-04-01 21:32:19 -04:00
KoalaTrapLord a81772c68a fix: set cwd=CFG.forge_dir on Popen so Forge finds res/ (#184) 2026-04-01 19:41:52 -04:00
KoalaTrapLord 99ca498b88 docs: add Unity TCGEngine Commander board implementation plan 2026-04-01 14:59:46 -04:00
KoalaTrapLord 395ad06cdd fix: resolve deck names to full .dck paths in build_java_command (#183) 2026-04-01 14:57:57 -04:00
KoalaTrapLord e4a6ce644d fix(#176): draw-game life capture, DrawGameAnalyzer, clock 6000→3000 (#181)
Captures life-at-timeout snapshots for every draw game via [DRAW] [PARSE-SUMMARY] log lines. Adds services/draw_game_analyzer.py with DrawGameSnapshot + summarize_draw_games(). Wires drawGames summary into GET /api/lab/result response. Lowers batch clock default from 6000 to 3000 (30s per-game). Closes #176.
2026-04-01 14:40:29 -04:00
KoalaTrapLord 5411fd6bf6 fix(#175): alternate win condition parser & winType tagging (#179)
Adds win_condition_parser.py, GameResult.win_type field, and winType stamping on both the Java/Forge and DeepSeek batch paths. Closes #175.
2026-04-01 14:28:10 -04:00
55 changed files with 37249 additions and 1706 deletions

No files matched your search

+21 -31
View File
@@ -1,36 +1,26 @@
# Commander AI Lab - Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
# Commander AI Lab — Environment Variables
# Copy this file to .env and fill in your values.
# start-lab.bat will auto-load .env if it exists.
# Ximilar API Key (card scanner / visual AI recognition)
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
# Get yours at: https://console.anthropic.com/settings/keys
# ── API Keys ──────────────────────────────────────────────────────
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Anthropic model for deck generation and coaching (Claude Opus recommended)
ANTHROPIC_MODEL=claude-opus-4-20250514
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
COACH_PROVIDER=anthropic
XIMILAR_API_KEY=your-ximilar-key-here
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
# Add your LAN IP if accessing from another machine on your network
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
# ── 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
# ── Dual-Model AI Brain ─────────────────────────────────────
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
BRAIN_API_BASE=http://localhost:11434
BRAIN_MODEL=gpt-oss:20b
BRAIN_TEMPERATURE=0.3
BRAIN_MAX_TOKENS=1024
BRAIN_TIMEOUT=120.0
# 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
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
VALIDATOR_ENABLED=true
VALIDATOR_API_BASE=http://localhost:11434
VALIDATOR_MODEL=deepseek-r1:14b
VALIDATOR_TEMPERATURE=0.0
VALIDATOR_MAX_TOKENS=2048
VALIDATOR_TIMEOUT=120.0
# ── 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
-31
View File
@@ -38,34 +38,3 @@ Thumbs.db
# Logs
*.log
# Precon decks — runtime data, downloaded locally by Forge/scripts
# Do not commit .dck files; they are large and machine-generated
precon-decks/*.dck
precon-decks/precon-index.json
# Phase 0: Local Scryfall card image cache (~1.5-2GB)
# Downloaded by scripts/download_card_images.py — never commit
static/card-images/
# Phase 0: SQLite image index (generated by scripts/build_image_index.py)
data/card_image_index.db
# Phase 6: AI decision logs and training sets (large runtime data)
data/decision_logs/
data/game_results.jsonl
data/training_sets/
# Unity generated / local config files
unity-client/Packages/packages-lock.json
unity-client/ProjectSettings/EditorBuildSettings.asset
unity-client/ProjectSettings/EditorSettings.asset
unity-client/ProjectSettings/ShaderGraphSettings.asset
unity-commander-ui/Packages/packages-lock.json
unity-commander-ui/ProjectSettings/EditorBuildSettings.asset
unity-commander-ui/ProjectSettings/EditorSettings.asset
# Third-party Unity assets — installed locally via Unity Package Manager / Asset Store
# These are large binary asset packs; each dev installs from their own license
unity-client/Assets/Eric VFX Studio/
unity-client/Assets/TcgEngine/
+343 -89
View File
@@ -1,46 +1,179 @@
# 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/Card-Forge/forge) | Built at `D:\ForgeCommander\forge-repo` |
### Setup (Windows)
```powershell
# 1. Build Forge (one-time)
@@ -51,17 +184,29 @@ mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
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.
+257
View File
@@ -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
}
-1
View File
@@ -1 +0,0 @@
# Phase 2: Dual-Brain AI Decision Engine
-5
View File
@@ -1,5 +0,0 @@
# Phase 6 - decision_logger.py
# Logs per-decision: game_id, turn, phase, player_id, personality,
# brain_used, prompt, raw_response, parsed_action, fallback_used
# Configurable via LOG_AI_DECISIONS env var
# See issue #173
-15
View File
@@ -1,15 +0,0 @@
# Phase 2 - game_orchestrator.py *** CORE PRIORITY ***
# GameAIOrchestrator: select_brain(), assign_personalities(), get_action()
# Brains: DeepSeek-R1-14B (complex) + GPT-OSS-20B (tactical)
# ChromaDB RAG for per-player card knowledge
# See issue #169
PERSONALITY_POOL = [
"aggressive -- attack early and often, prioritize players with lowest life totals",
"control -- counter key spells, hold mana open, only act when clearly advantageous",
"combo -- develop mana and card advantage quietly, avoid conflict until ready to win",
"political -- make deals, attack whoever is ahead, never fully commit to one target",
"pillowfort -- build defensive pieces, let others fight, win slowly through attrition",
"stompy -- play the biggest threats as fast as possible, ignore table politics",
"tempo -- disrupt opponents early, trade efficiently, stay ahead on board",
]
-12
View File
@@ -1,12 +0,0 @@
# Phase 0: Card image cache and data enrichment
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCache, ImageCacheResult
from commander_ai_lab.cards.card_data_enricher import enrich_card, enrich_card_list, enrich_game_state
__all__ = [
"get_image_cache",
"ImageCache",
"ImageCacheResult",
"enrich_card",
"enrich_card_list",
"enrich_game_state",
]
@@ -1,116 +0,0 @@
"""
Phase 0 — card_data_enricher.py
Injects art_url and art_url_back into card data dicts/DTOs
before they are broadcast to Unity via WebSocket.
Used by:
- Phase 1 game_state_parser.py (enriches cards in GameStateDTO)
- Phase 3 WebSocket handler (enriches full game state before broadcast)
Lookup chain per card:
1. scryfall_id present -> ImageCache.lookup_by_id()
2. scryfall_id absent -> ImageCache.lookup_by_name() (Forge card name)
3. Both fail -> Scryfall CDN fallback URL
4. CDN fallback absent -> empty string (Unity shows card back texture)
See issue #167
"""
from __future__ import annotations
import logging
from typing import Any
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCacheResult
log = logging.getLogger("commander_ai_lab.cards.card_data_enricher")
# Scryfall CDN fallback when both local cache and DB are unavailable
_CDN_FRONT = "https://cards.scryfall.io/normal/front/{d1}/{d2}/{sid}.jpg"
_CDN_BACK = "https://cards.scryfall.io/normal/back/{d1}/{d2}/{sid}.jpg"
def _cdn_fallback_urls(scryfall_id: str) -> tuple[str, str | None]:
if not scryfall_id:
return "", None
d1 = scryfall_id[0]
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
front = _CDN_FRONT.format(d1=d1, d2=d2, sid=scryfall_id)
back = _CDN_BACK.format(d1=d1, d2=d2, sid=scryfall_id)
return front, back
def enrich_card(card: dict[str, Any]) -> dict[str, Any]:
"""
Inject art_url and art_url_back into a single card dict.
Mutates in place AND returns the card for convenience.
Input card dict is expected to have at minimum:
- 'name': str (card name from Forge)
- 'scryfall_id': str (optional but preferred)
After enrichment the card will have:
- 'art_url': str front face image URL
- 'art_url_back': str|None back face image URL (DFCs only)
- 'has_back': bool
"""
# Skip if already enriched
if card.get("art_url"):
return card
cache = get_image_cache()
result: ImageCacheResult | None = None
scryfall_id = card.get("scryfall_id", "")
if scryfall_id:
result = cache.lookup_by_id(scryfall_id)
if result is None:
name = card.get("name", "")
if name:
result = cache.lookup_by_name(name)
if result is not None:
card["art_url"] = result.front_url
card["art_url_back"] = result.back_url
card["has_back"] = result.has_back
else:
# Hard fallback: build CDN URL from scryfall_id if available
front, back = _cdn_fallback_urls(scryfall_id)
card["art_url"] = front
card["art_url_back"] = back if back else None
card["has_back"] = False
if not front:
log.warning("No art URL resolved for card: %s (id=%s)", card.get("name"), scryfall_id)
return card
def enrich_card_list(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Enrich a list of card dicts. Mutates in place."""
for card in cards:
enrich_card(card)
return cards
def enrich_game_state(state: dict[str, Any]) -> dict[str, Any]:
"""
Enrich all card lists in a full GameStateDTO dict.
Walks all players and all zones: hand, battlefield, graveyard, exile, command_zone.
Mutates in place AND returns state.
"""
zone_keys = ("hand", "battlefield", "graveyard", "exile", "command_zone")
for player in state.get("players", []):
for zone in zone_keys:
cards = player.get(zone)
if isinstance(cards, list):
enrich_card_list(cards)
# Also enrich the stack
for stack_item in state.get("stack", []):
card = stack_item.get("card")
if isinstance(card, dict):
enrich_card(card)
return state
-167
View File
@@ -1,167 +0,0 @@
"""
Phase 0 — image_cache.py
SQLite-backed card image lookup service.
Provides:
ImageCache — sync lookup (used by download scripts, CLI tools)
AsyncImageCache — async lookup (used by FastAPI request handlers)
Lookup priority:
1. SQLite index by scryfall_id (exact, fast)
2. SQLite index by card name (case-insensitive, Forge card name format)
3. CDN fallback URL (scryfall.io) — no local file, but still a valid URL
See issue #167
"""
from __future__ import annotations
import logging
import sqlite3
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.cards.image_cache")
PROJECT_ROOT = Path(__file__).parent.parent.parent
DB_PATH = PROJECT_ROOT / "data" / "card_image_index.db"
STATIC_BASE_URL = "/static/card-images"
# Scryfall CDN URL pattern for fallback
# Format: https://cards.scryfall.io/normal/front/{d1}/{d2}/{scryfall_id}.jpg
# where d1/d2 are first two hex chars of the UUID
def _cdn_url(scryfall_id: str, face: str = "front") -> str:
"""Build a Scryfall CDN URL for a card face. Used as fallback."""
d1 = scryfall_id[0] if scryfall_id else "0"
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
return f"https://cards.scryfall.io/normal/{face}/{d1}/{d2}/{scryfall_id}.jpg"
class ImageCacheResult:
"""Result of a card image lookup."""
__slots__ = ("scryfall_id", "name_raw", "front_url", "back_url", "has_back", "is_local")
def __init__(
self,
scryfall_id: str,
name_raw: str,
front_url: str,
back_url: Optional[str],
has_back: bool,
is_local: bool,
):
self.scryfall_id = scryfall_id
self.name_raw = name_raw
self.front_url = front_url
self.back_url = back_url
self.has_back = has_back
self.is_local = is_local
def to_dict(self) -> dict:
return {
"scryfall_id": self.scryfall_id,
"name": self.name_raw,
"art_url": self.front_url,
"art_url_back": self.back_url,
"has_back": self.has_back,
"is_local": self.is_local,
}
class ImageCache:
"""Synchronous SQLite-backed card image cache."""
def __init__(self, db_path: Path = DB_PATH, base_url: str = STATIC_BASE_URL):
self._db_path = db_path
self._base_url = base_url.rstrip("/")
self._conn: Optional[sqlite3.Connection] = None
def _get_conn(self) -> sqlite3.Connection:
if self._conn is None:
if not self._db_path.exists():
log.warning(
"Image index DB not found at %s. Run scripts/build_image_index.py first.",
self._db_path
)
return None
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
return self._conn
def _row_to_result(self, row: sqlite3.Row) -> ImageCacheResult:
scryfall_id = row["scryfall_id"]
front_path = row["front_path"]
back_path = row["back_path"]
has_back = bool(row["has_back"])
if front_path:
front_url = f"{self._base_url}/{scryfall_id}_front.jpg"
back_url = f"{self._base_url}/{scryfall_id}_back.jpg" if has_back else None
is_local = True
else:
# CDN fallback
front_url = _cdn_url(scryfall_id, "front")
back_url = _cdn_url(scryfall_id, "back") if has_back else None
is_local = False
return ImageCacheResult(
scryfall_id=scryfall_id,
name_raw=row["name_raw"],
front_url=front_url,
back_url=back_url,
has_back=has_back,
is_local=is_local,
)
def lookup_by_id(self, scryfall_id: str) -> Optional[ImageCacheResult]:
"""Look up card by Scryfall UUID."""
conn = self._get_conn()
if conn is None:
return self._cdn_fallback(scryfall_id)
row = conn.execute(
"SELECT * FROM card_images WHERE scryfall_id = ?", (scryfall_id,)
).fetchone()
if row:
return self._row_to_result(row)
return self._cdn_fallback(scryfall_id)
def lookup_by_name(self, name: str) -> Optional[ImageCacheResult]:
"""Look up card by name (case-insensitive). Returns most recent printing."""
conn = self._get_conn()
if conn is None:
return None
row = conn.execute(
"SELECT * FROM card_images WHERE name = ? LIMIT 1",
(name.lower().strip(),)
).fetchone()
if row:
return self._row_to_result(row)
return None
def _cdn_fallback(self, scryfall_id: str) -> ImageCacheResult:
"""Return CDN URLs when local cache is unavailable."""
return ImageCacheResult(
scryfall_id=scryfall_id,
name_raw="",
front_url=_cdn_url(scryfall_id, "front"),
back_url=None,
has_back=False,
is_local=False,
)
def close(self):
if self._conn:
self._conn.close()
self._conn = None
# Module-level singleton for use by FastAPI route handlers
_cache: Optional[ImageCache] = None
def get_image_cache() -> ImageCache:
"""Get the module-level ImageCache singleton."""
global _cache
if _cache is None:
_cache = ImageCache()
return _cache
-1
View File
@@ -1 +0,0 @@
# Phase 1: Forge subprocess bridge
@@ -1,3 +0,0 @@
# Phase 6 - game_result_logger.py
# Captures winner, turn count, elimination order on Forge game end
# See issue #173
@@ -1,3 +0,0 @@
# Phase 1 - game_state_parser.py
# Converts Forge stdout text -> canonical GameStateDTO JSON
# See issue #168
@@ -1,4 +0,0 @@
# Phase 1 - subprocess_manager.py
# ForgeGameSession + ForgeSubprocessManager
# Drives interactive Commander games via Forge stdin/stdout pipe
# See issue #168
-1
View File
@@ -1 +0,0 @@
# Phase 6: ML Training Integration
@@ -1,4 +0,0 @@
# Phase 6 - training_data_builder.py
# Converts decision_log + game_result -> (state, action, reward) tuples
# Feeds existing PPO pipeline
# See issue #173
-2
View File
@@ -1,2 +0,0 @@
# Runtime data directory
# card_image_index.db, decision_logs/, game_results.jsonl, training_sets/
+785
View File
@@ -0,0 +1,785 @@
# Commander-AI-Lab — Unity TCGEngine Commander Board: Implementation Plan (v5)
> **Architecture Principle:** Forge = Rules Authority · FastAPI = Hub · TCGEngine Unity = Renderer · Dual-Brain AI = Decision Engine
---
## System Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ UNITY (TCGEngine — 2D Board) │
│ PlayerArea x4: Hand | Library | Graveyard | Exile | Command │
│ Battlefield zones per player · Card art from local cache │
│ GameNetworkManager.cs ←── WebSocket ──► FastAPI │
└─────────────────────────────────────────────────────────────────┘
▲ ▼
┌─────────────────────────────────────────────────────────────────┐
│ FASTAPI (commander-ai-lab) │
│ /ws/game — WebSocket relay │
│ /game/action — receive player/AI action, forward to Forge │
│ ForgeSubprocessManager — stdin/stdout pipe to Forge JVM │
│ CardEnricher — injects art_url from local image cache │
│ AIOrchestrator — routes decisions to DeepSeek-R1 or GPT-OSS │
│ ChromaDB RAG — card knowledge scoped per player deck │
│ ai_decisions.jsonl — logs every AI decision (PPO dataset) │
└─────────────────────────────────────────────────────────────────┘
▲ ▼
┌─────────────────────────────────────────────────────────────────┐
│ FORGE (Java — Headless Rules Engine) │
│ Full Commander rules: tax, 21-commander-damage, 40 life │
│ 4-player turn queue · Zone redirects · State-based actions │
│ Emits game state JSON to stdout · Receives actions via stdin │
└─────────────────────────────────────────────────────────────────┘
```
---
## Dual-Brain AI Decision Flow
This is the **core feature** of Commander-AI-Lab. Every AI player decision runs through this pipeline:
```
Forge emits GameStateDTO (JSON)
└─► FastAPI detects active player is AI
└─► select_brain(phase, stack_size)
├─ DeepSeek-R1-14B → combat, stack decisions, removal
└─ GPT-OSS-20B → main phase, ramp, card advantage
└─► ChromaDB.query(player_deck_cards, top_k=5)
└─► build_prompt(state, rag_context, personality)
└─► LLM.generate(prompt)
└─► parse_action_json(response)
├─ validate vs legal_actions[]
├─ log to ai_decisions.jsonl
└─► Forge stdin ← chosen action
```
### Brain Selector (`ai/brain_selector.py`)
```python
from enum import Enum
class Brain(Enum):
DEEPSEEK = "deepseek-r1:14b"
GPT_OSS = "gpt-oss-20b" # adjust to your Ollama model tag
def select_brain(phase: str, stack_size: int) -> Brain:
"""
DeepSeek-R1: combat math, instant-speed stack decisions, targeted removal.
GPT-OSS: sorcery-speed strategy, ramp sequencing, card selection.
"""
combat_phases = {"declare_attackers", "declare_blockers", "combat_damage"}
if phase in combat_phases or stack_size > 0:
return Brain.DEEPSEEK
return Brain.GPT_OSS
```
### Prompt Builder (`ai/prompt_builder.py`)
```python
from chromadb import Client
from .personality import get_personality_prompt
chroma = Client()
card_collection = chroma.get_collection("mtg_cards")
def build_prompt(state: dict, player_id: str, personality: str) -> str:
# Pull card knowledge scoped to THIS player's deck
player_cards = [c["name"] for c in state["players"][player_id]["library"]]
rag_results = card_collection.query(
query_texts=player_cards[:10],
n_results=5
)
card_context = "\n".join(rag_results["documents"][0])
personality_prompt = get_personality_prompt(personality)
return f"""You are an AI playing Magic: The Gathering Commander.
{personality_prompt}
## Current Game State
- Phase: {state['phase']}
- Active Player: {player_id}
- Life Totals: {state['life_totals']}
- Commander Damage Dealt: {state['commander_damage']}
- Stack: {state['stack']}
- Your Hand: {[c['name'] for c in state['players'][player_id]['hand']]}
- Battlefield: {[c['name'] for c in state['players'][player_id]['battlefield']]}
- Available Mana: {state['players'][player_id]['mana_pool']}
## Legal Actions
{state['legal_actions']}
## Card Knowledge (RAG Context)
{card_context}
Respond with a single JSON object:
{{"action": "<action_type>", "card_id": "<id_or_null>", "targets": [], "reasoning": "<brief>"}}
"""
```
### Personality System (`ai/personality.py`)
```python
import random
PERSONALITIES = {
"aggro_beatdown": "You play aggressively. Attack every turn. Remove blockers. Win through combat damage as fast as possible.",
"control_lockdown": "You play control. Counter key threats. Hold up mana for interaction. Win only when the board is locked.",
"combo_enabler": "You dig for your combo pieces. Every decision is evaluated by how it advances your win condition.",
"political_diplomat": "You form alliances. Attack the leader. Make deals. Never be the archenemy.",
"voltron_commander": "Everything buffs your commander. Protect it at all costs. Win through commander damage.",
"stax_prison": "You tax and restrict opponents. Deploy hate pieces. Win through attrition and resource denial.",
"tempo_value": "You trade efficiently. Generate card advantage. Win by being ahead on resources.",
"chaos_wildcard": "You make unpredictable decisions. Prioritize fun and chaos over optimal play.",
}
def assign_personalities(player_ids: list[str], deck_archetypes: dict) -> dict[str, str]:
"""
Assign a personality per AI player at game creation.
80% chance: archetype-matched personality.
20% chance: random wildcard for unpredictability.
"""
assignments = {}
for pid in player_ids:
archetype = deck_archetypes.get(pid, "tempo_value")
if random.random() < 0.20:
assignments[pid] = random.choice(list(PERSONALITIES.keys()))
else:
assignments[pid] = archetype
return assignments
def get_personality_prompt(personality: str) -> str:
return f"## Your Playstyle\n{PERSONALITIES.get(personality, PERSONALITIES['tempo_value'])}"
```
### AI Orchestrator (`ai/ai_orchestrator.py`)
```python
import json, time, logging
from pathlib import Path
from .brain_selector import select_brain, Brain
from .prompt_builder import build_prompt
import ollama
LOG_PATH = Path("logs/ai_decisions.jsonl")
LOG_PATH.parent.mkdir(exist_ok=True)
async def get_ai_action(state: dict, player_id: str, personality: str) -> dict:
brain = select_brain(state["phase"], len(state["stack"]))
prompt = build_prompt(state, player_id, personality)
t_start = time.time()
try:
response = ollama.chat(
model=brain.value,
messages=[{"role": "user", "content": prompt}],
options={"temperature": 0.3, "num_predict": 256}
)
raw = response["message"]["content"]
# Extract JSON from response (model may wrap in markdown)
action = json.loads(raw[raw.find("{"):raw.rfind("}")+1])
except Exception as e:
logging.warning(f"AI parse failed ({brain.value}): {e}. Passing turn.")
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "parse_error"}
# Validate against Forge legal actions
if action.get("action") not in [a["type"] for a in state["legal_actions"]]:
logging.warning(f"Illegal action returned by {brain.value}. Falling back to pass.")
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "illegal_action"}
# Log for PPO training dataset
log_entry = {
"timestamp": time.time(),
"player_id": player_id,
"brain": brain.value,
"personality": personality,
"phase": state["phase"],
"action": action,
"reasoning": action.get("reasoning", ""),
"latency_ms": round((time.time() - t_start) * 1000),
}
with open(LOG_PATH, "a") as f:
f.write(json.dumps(log_entry) + "\n")
return action
```
---
## Phase 0 — Local Card Image Cache
**Build this first.** Every downstream system depends on local art URLs.
### Step 1 — Bulk Download (`scripts/download_card_images.py`)
```python
import requests, json, os
from pathlib import Path
from tqdm import tqdm
BULK_URL = "https://api.scryfall.com/bulk-data/default-cards"
IMAGE_DIR = Path("static/card-images")
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
def download_images():
# Get bulk data URL
meta = requests.get(BULK_URL).json()
cards = requests.get(meta["download_uri"], stream=True).json()
for card in tqdm(cards, desc="Downloading card images"):
image_uris = card.get("image_uris") or {}
url = image_uris.get("normal")
if not url:
continue # skip DFCs without top-level image_uris
safe_name = card["name"].replace("/", "_").replace(" ", "_")
set_code = card.get("set", "unknown")
filename = IMAGE_DIR / set_code / f"{safe_name}_{card['collector_number']}.jpg"
filename.parent.mkdir(parents=True, exist_ok=True)
if filename.exists():
continue # already cached
img_data = requests.get(url, timeout=10).content
filename.write_bytes(img_data)
if __name__ == "__main__":
download_images()
```
### Step 2 — Build SQLite Index (`scripts/build_image_index.py`)
```python
import sqlite3
from pathlib import Path
IMAGE_DIR = Path("static/card-images")
DB_PATH = Path("data/card_images.db")
DB_PATH.parent.mkdir(exist_ok=True)
def build_index():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS card_images (
name TEXT PRIMARY KEY,
path TEXT NOT NULL
)
""")
for img_path in IMAGE_DIR.rglob("*.jpg"):
card_name = img_path.stem.rsplit("_", 1)[0].replace("_", " ")
conn.execute("INSERT OR REPLACE INTO card_images VALUES (?, ?)",
(card_name, str(img_path)))
conn.commit()
conn.close()
if __name__ == "__main__":
build_index()
```
### Step 3 — Card Enricher (`api/card_enricher.py`)
```python
import sqlite3
from pathlib import Path
DB_PATH = Path("data/card_images.db")
STATIC_URL = "http://localhost:8000/static/card-images"
def enrich_card(card: dict) -> dict:
"""Inject local art_url into every CardDTO before sending to Unity."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.execute("SELECT path FROM card_images WHERE name = ?", (card["name"],))
row = cursor.fetchone()
conn.close()
if row:
relative = Path(row[0]).relative_to("static/card-images")
card["art_url"] = f"{STATIC_URL}/{relative}"
else:
card["art_url"] = f"{STATIC_URL}/card_back.jpg"
return card
```
### Step 4 — FastAPI Static Mount (`main.py` addition)
```python
from fastapi.staticfiles import StaticFiles
app.mount("/static/card-images", StaticFiles(directory="static/card-images"), name="card-images")
```
---
## Phase 1 — Forge Bridge (Rules Engine)
### ForgeSubprocessManager (`forge/subprocess_manager.py`)
```python
import subprocess, json, asyncio, threading
from pathlib import Path
FORGE_JAR = Path("forge/forge-game.jar")
class ForgeSubprocessManager:
def __init__(self):
self.process = None
self._lock = threading.Lock()
def start(self, deck_paths: list[str], format: str = "commander"):
cmd = [
"java", "-jar", str(FORGE_JAR),
"--headless",
"--format", format,
"--decks", *deck_paths,
"--output", "json"
]
self.process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
def read_state(self) -> dict:
with self._lock:
line = self.process.stdout.readline()
return json.loads(line)
def send_action(self, action: dict):
with self._lock:
self.process.stdin.write(json.dumps(action) + "\n")
self.process.stdin.flush()
def stop(self):
if self.process:
self.process.terminate()
```
### Data Transfer Objects (`api/dtos.py`)
```python
from pydantic import BaseModel
from typing import Optional
class CardDTO(BaseModel):
id: str
name: str
type: str
power: Optional[str]
toughness: Optional[str]
mana_cost: str
art_url: str = ""
tapped: bool = False
zone: str = "hand"
class PlayerDTO(BaseModel):
id: str
life: int
commander_damage: dict[str, int]
commander_tax: int
hand: list[CardDTO]
battlefield: list[CardDTO]
graveyard: list[CardDTO]
exile: list[CardDTO]
command_zone: list[CardDTO]
library_count: int
mana_pool: dict[str, int]
is_ai: bool
personality: Optional[str]
class GameStateDTO(BaseModel):
game_id: str
phase: str
active_player: str
priority: str
turn_number: int
players: list[PlayerDTO]
stack: list[dict]
legal_actions: list[dict]
winner: Optional[str]
```
---
## Phase 2 — FastAPI WebSocket Game Server
### Game Loop (`api/game_loop.py`)
```python
import asyncio, uuid
from .dtos import GameStateDTO
from forge.subprocess_manager import ForgeSubprocessManager
from ai.ai_orchestrator import get_ai_action
from ai.personality import assign_personalities
from api.card_enricher import enrich_card
active_games: dict[str, dict] = {}
async def start_game(deck_paths: list[str], ai_players: list[str]) -> str:
game_id = str(uuid.uuid4())
forge = ForgeSubprocessManager()
forge.start(deck_paths)
# Assign AI personalities at game creation
archetypes = detect_archetypes(deck_paths, ai_players) # ChromaDB query
personalities = assign_personalities(ai_players, archetypes)
active_games[game_id] = {
"forge": forge,
"ai_players": ai_players,
"personalities": personalities,
"websockets": []
}
asyncio.create_task(game_loop(game_id))
return game_id
async def game_loop(game_id: str):
game = active_games[game_id]
forge = game["forge"]
while True:
raw_state = forge.read_state()
if raw_state.get("winner"):
await broadcast(game_id, raw_state)
break
# Enrich every card with local art_url
state = enrich_state(raw_state)
await broadcast(game_id, state)
priority_player = state["priority"]
if priority_player in game["ai_players"]:
personality = game["personalities"][priority_player]
action = await get_ai_action(state, priority_player, personality)
forge.send_action(action)
# else: wait for Unity client to send action via WebSocket
async def broadcast(game_id: str, state: dict):
game = active_games[game_id]
for ws in game["websockets"]:
await ws.send_json(state)
def enrich_state(state: dict) -> dict:
for player in state["players"]:
for zone in ["hand", "battlefield", "graveyard", "exile", "command_zone"]:
player[zone] = [enrich_card(c) for c in player.get(zone, [])]
return state
```
### WebSocket Endpoint (`api/routes/game.py`)
```python
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.game_loop import active_games, start_game
router = APIRouter()
@router.websocket("/ws/game/{game_id}")
async def game_ws(websocket: WebSocket, game_id: str):
await websocket.accept()
game = active_games.get(game_id)
if not game:
await websocket.close(code=4004)
return
game["websockets"].append(websocket)
try:
while True:
data = await websocket.receive_json()
# Human player action forwarded directly to Forge
game["forge"].send_action(data)
except WebSocketDisconnect:
game["websockets"].remove(websocket)
```
---
## Phase 3 — Unity TCGEngine Board (2D)
### Project Layout
```
unity-commander-ui/
├── Assets/
│ ├── TCGEngine/ ← TCGEngine base (unmodified)
│ ├── Commander/
│ │ ├── Scripts/
│ │ │ ├── GameNetworkManager.cs ← WebSocket ↔ FastAPI
│ │ │ ├── CommanderBoardUI.cs ← 4-player layout controller
│ │ │ ├── PlayerAreaUI.cs ← per-player zone panel
│ │ │ ├── CardImageLoader.cs ← local cache texture fetcher
│ │ │ ├── ZonePanel.cs ← Hand/Library/Graveyard/Exile/Command
│ │ │ └── AIThinkingIndicator.cs ← spinner while AI brain is deciding
│ │ └── Prefabs/
│ │ ├── CardPrefab.prefab
│ │ ├── PlayerAreaPrefab.prefab
│ │ └── ZonePanelPrefab.prefab
│ └── Scenes/
│ └── CommanderBoard.unity
```
### Board Layout (matches screenshot)
```
┌──────────────────────────────────────────────────────────────────┐
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 3) │
│ [COMMAND] │
├──────────────────────────────┬───────────────────────────────────┤
│ │ │
│ │ │
│ BATTLEFIELD │ BATTLEFIELD │
│ (Player 1) │ (Player 2) │
│ BATTLEFIELD (center) │
│ BATTLEFIELD │ BATTLEFIELD │
│ (Player 4) │ (Player 3) │
│ │ │
├──────────────────────────────┴───────────────────────────────────┤
│ [COMMAND] │
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 1) │
└──────────────────────────────────────────────────────────────────┘
```
### GameNetworkManager.cs
```csharp
using UnityEngine;
using NativeWebSocket;
using Newtonsoft.Json;
public class GameNetworkManager : MonoBehaviour
{
public static GameNetworkManager Instance { get; private set; }
[SerializeField] private string serverUrl = "ws://localhost:8000/ws/game/";
[SerializeField] private string gameId;
private WebSocket _socket;
public delegate void GameStateReceived(GameStateDTO state);
public event GameStateReceived OnGameStateReceived;
async void Awake()
{
Instance = this;
_socket = new WebSocket($"{serverUrl}{gameId}");
_socket.OnMessage += (bytes) =>
{
var json = System.Text.Encoding.UTF8.GetString(bytes);
var state = JsonConvert.DeserializeObject<GameStateDTO>(json);
UnityMainThreadDispatcher.Enqueue(() => OnGameStateReceived?.Invoke(state));
};
await _socket.Connect();
}
public async void SendAction(object action)
{
var json = JsonConvert.SerializeObject(action);
await _socket.SendText(json);
}
void Update() => _socket?.DispatchMessageQueue();
async void OnDestroy() => await _socket?.Close();
}
```
### CardImageLoader.cs
```csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;
public class CardImageLoader : MonoBehaviour
{
private static Dictionary<string, Texture2D> _cache = new();
public static IEnumerator LoadCardArt(string artUrl, RawImage target)
{
if (_cache.TryGetValue(artUrl, out var cached))
{
target.texture = cached;
yield break;
}
using var req = UnityWebRequestTexture.GetTexture(artUrl);
yield return req.SendWebRequest();
if (req.result == UnityWebRequest.Result.Success)
{
var tex = DownloadHandlerTexture.GetContent(req);
_cache[artUrl] = tex;
target.texture = tex;
}
else
{
Debug.LogWarning($"Failed to load card art: {artUrl}");
}
}
}
```
### AIThinkingIndicator.cs
```csharp
using UnityEngine;
using UnityEngine.UI;
/// Shows a spinner + brain label while the AI is deciding.
public class AIThinkingIndicator : MonoBehaviour
{
[SerializeField] private GameObject spinnerPanel;
[SerializeField] private Text brainLabel;
[SerializeField] private Text personalityLabel;
public void Show(string brain, string personality)
{
spinnerPanel.SetActive(true);
brainLabel.text = $"🧠 {brain}";
personalityLabel.text = $"🎭 {personality}";
}
public void Hide() => spinnerPanel.SetActive(false);
}
```
---
## Phase 4 — Commander Zone UIs
### Zone Definitions per Player
| Zone Panel | Content | Notes |
|---|---|---|
| `LIBRARY` | Count only | Click → draw/look at top |
| `GRAVEYARD` | Scrollable card list | Full card art on hover |
| `EXILE` | Scrollable card list | Includes face-down indicators |
| `HAND` | Wide panel, card art | Hidden to opponents |
| `COMMAND` | Commander card(s) | Shows commander tax counter |
| `BATTLEFIELD` | Open grid | Tapped/untapped state, counters |
### Commander-Specific UI Elements
```csharp
public class CommanderZoneUI : MonoBehaviour
{
[SerializeField] private Text taxCounter; // "Tax: +{2N}"
[SerializeField] private RawImage commanderArt;
[SerializeField] private Text commanderDamageDealt; // per opponent
public void Refresh(PlayerDTO player)
{
int tax = player.CommanderTax;
taxCounter.text = tax > 0 ? $"+{tax * 2}" : "";
if (player.CommandZone.Count > 0)
StartCoroutine(CardImageLoader.LoadCardArt(
player.CommandZone[0].ArtUrl, commanderArt));
}
}
```
---
## Phase 5 — Commander Rules Validation Layer
Forge handles all rules natively. FastAPI validates the AI response is within the legal actions list Forge returns — no custom rules code needed in Python or Unity.
Key Commander rules confirmed handled by Forge headless:
- **Commander Tax** — +2 generic per previous cast from command zone
- **21 Commander Damage** — tracked per attacker/defender pair
- **40 Starting Life**
- **Zone Redirect** — commander goes to command zone instead of graveyard/exile (player choice)
- **Color Identity** — deck validation at game start
- **Singleton** — deck validation at game start
---
## Phase 6 — AI Decision Logging & PPO Pipeline
Every AI decision written to `logs/ai_decisions.jsonl`:
```json
{
"timestamp": 1743520800.123,
"player_id": "player_2",
"brain": "deepseek-r1:14b",
"personality": "aggro_beatdown",
"phase": "declare_attackers",
"action": {"action": "attack", "card_id": "c_042", "targets": ["player_1"], "reasoning": "Open attack, no blockers visible"},
"latency_ms": 847
}
```
This log becomes the **supervised learning dataset** for the next PPO training iteration. Each (state, action) pair from winning games is a positive training example.
---
## Project Directory Structure
```
commander-ai-lab/
├── main.py ← FastAPI app, mounts static files
├── api/
│ ├── dtos.py ← Pydantic DTOs
│ ├── card_enricher.py ← Injects art_url into CardDTOs
│ ├── game_loop.py ← Main game orchestration loop
│ └── routes/
│ └── game.py ← WebSocket + REST endpoints
├── ai/
│ ├── ai_orchestrator.py ← Main AI decision entry point
│ ├── brain_selector.py ← DeepSeek-R1 vs GPT-OSS routing
│ ├── prompt_builder.py ← Game state + RAG → prompt
│ └── personality.py ← 8 personalities + assignment
├── forge/
│ └── subprocess_manager.py ← Forge JVM stdin/stdout bridge
├── scripts/
│ ├── download_card_images.py ← One-time Scryfall bulk download
│ └── build_image_index.py ← SQLite name→path index
├── static/
│ └── card-images/ ← ~2GB local image cache
│ └── {set_code}/
│ └── {CardName}_{num}.jpg
├── data/
│ └── card_images.db ← SQLite image index
├── logs/
│ └── ai_decisions.jsonl ← PPO training dataset
├── unity-commander-ui/ ← Unity 2D TCGEngine project
└── docs/
└── implementation_plan.md ← This document
```
---
## Phased Timeline
| Phase | Scope | Est. Time |
|---|---|---|
| **0** | Scryfall image download + SQLite index + FastAPI static mount | 1 day |
| **1** | Forge subprocess bridge + DTO definitions | 2–3 days |
| **2** | AI Orchestrator + brain selector + prompt builder + personality system | 3–4 days |
| **3** | Unity TCGEngine project setup + WebSocket GameNetworkManager | 3–4 days |
| **4** | PlayerAreaUI + ZonePanels + CardImageLoader + board layout | 3–4 days |
| **5** | Commander zone UI (tax counter, commander damage, zone redirect) | 1–2 days |
| **6** | AI decision logging + end-to-end game loop test | 1–2 days |
**Total estimated: ~3 weeks to first playable Commander game with live AI opponents.**
---
## Key Design Decisions
| Decision | Rationale |
|---|---|
| Forge = rules authority | Forge already handles all Commander rules natively. No custom rules code. |
| TCGEngine = dumb renderer | All game logic lives in Forge. Unity only renders state it receives. |
| FastAPI = hub | Bridges Forge ↔ Unity ↔ AI. Single point of truth. |
| Dual-brain routing | DeepSeek-R1 excels at analytical/math decisions; GPT-OSS at strategic sequencing. |
| ChromaDB scoped per player | RAG context relevant to the active player's deck, not global card knowledge. |
| Personality persists per game | Consistent playstyle per AI across all 4 players per session. |
| Local image cache | Eliminates network latency for card art. ~2GB one-time download. |
| ai_decisions.jsonl | Every AI decision is a future PPO training sample. |
+56 -35
View File
@@ -43,8 +43,6 @@ from routes.coach import router as coach_router, init_coach_service
from routes.ml import router as ml_router
from routes.ws_game import router as ws_game_router
from routes.game import router as game_router
# Phase 0: card art endpoints
from routes.card_art import router as card_art_router
log = logging.getLogger("commander_ai_lab.api")
@@ -52,8 +50,11 @@ _DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:3000,http://localhost
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
# Module-level handle so lifespan can call .load() after routes are registered.
_POLICY_SVC = None
# ── Lifespan (replaces deprecated @app.on_event) ──────────────────────────────────────────────────
# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
@asynccontextmanager
async def _lifespan(application: FastAPI):
# ──── STARTUP ────
@@ -79,7 +80,6 @@ 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:
@@ -94,20 +94,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
@@ -138,8 +135,24 @@ app.include_router(deckgen_router)
app.include_router(coach_router)
app.include_router(ws_game_router)
app.include_router(ml_router)
app.include_router(game_router) # live game session routes
app.include_router(card_art_router) # Phase 0: /card-art/* + /api/card-image-*
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 ─────────────────────────────────────────────────────────────────────────────
@@ -152,24 +165,21 @@ async def health_check():
"""Health check endpoint for load balancers and Unity client polling."""
return {"status": "ok"}
# ── Static mounts (MUST come after all @app.get / router includes) ────────────────────────
# ── Static UI (MUST come after all @app.get / include_router calls) ──────────────────────────
from pathlib import Path as _Path
# Phase 0: Serve local card images at /static/card-images/
# MUST be mounted before the legacy UI wildcard to avoid being shadowed.
_card_images_dir = _Path(__file__).parent / "static" / "card-images"
_card_images_dir.mkdir(parents=True, exist_ok=True)
app.mount(
"/static/card-images",
StaticFiles(directory=str(_card_images_dir)),
name="card-images"
)
# Legacy UI / SPA (wildcard — MUST come last)
_legacy_ui_dir = _Path(__file__).parent / "ui"
_lan_client_dir = _Path(__file__).parent / "lan-client"
_precon_decks_dir = _Path(__file__).parent / "precon-decks"
_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")
if _legacy_ui_dir.exists():
app.mount("/", StaticFiles(directory=str(_legacy_ui_dir), html=True), name="ui")
elif _spa_dir.exists():
@@ -186,14 +196,16 @@ elif _spa_dir.exists():
return FileResponse(str(_spa_dir / "index.html"))
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
def _parse_args():
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
# Support both FORGE_DIR and FORGE_WORKING_DIR (alias for backwards compat)
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR") or os.environ.get("FORGE_WORKING_DIR", ""))
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
p.add_argument("--results-dir", default=os.environ.get("RESULTS_DIR", ""))
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
@@ -268,7 +280,7 @@ def _resolve_forge_jar() -> str:
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge" / "forge-gui-desktop" / "target",
]
forge_dir_env = os.environ.get("FORGE_DIR", "")
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
if forge_dir_env:
fd = Path(forge_dir_env)
candidates.append(fd.parent / "forge-gui-desktop" / "target")
@@ -333,6 +345,15 @@ def main():
CFG.ximilar_api_key = args.ximilar_key
CFG.anthropic_api_key = args.anthropic_key
# Wire RESULTS_DIR: use explicit env/arg if set, otherwise keep default "results"
# but resolve it to an absolute path relative to this file so it works
# regardless of what directory the server is launched from.
if args.results_dir:
CFG.results_dir = args.results_dir
else:
# Default: results/ next to lab_api.py (always absolute)
CFG.results_dir = str(Path(__file__).parent / "results")
from services.forge_runner import get_java17
j17 = get_java17()
File diff suppressed because it is too large. Load diff
+25640
View File
File diff suppressed because it is too large. Load diff
+651
View File
@@ -0,0 +1,651 @@
<!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>&#x21A9;</button>
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>&#x21AA;</button>
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">&#x2747;</button>
<button class="btn-util" onclick="openDiceModal()" title="Dice &amp; Coins (G)">&#x1F3B2;</button>
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">&#x2699;</button>
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">&#x1F50D;</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">&#x2212;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x1F451;</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">&#x2212;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x1F451;</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">&#x1F451;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x2212;</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">&#x1F451;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x2212;</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>
<!-- 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">&#x2715;</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">&#x2039;</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()">&#x2715;</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 &amp; Coins</span>
<button class="btn-close" onclick="closeDiceModal()">&#x2715;</button>
</div>
<div class="dice-result-display" id="dice-result-display">&#x2014;</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()">&#x2715;</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()">&#x2715;</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()">&#x2715;</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()">&#x2715;</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()">&#x1F3B2; Dice</button>
<button class="btn btn-gold" onclick="openCmdDmgMatrix()">CMD Damage Matrix</button>
<button class="btn btn-cancel" onclick="checkStateBasedActions()">Check SBA</button>
<button class="btn btn-cancel" onclick="autoArrangeBattlefield()">Auto-Arrange</button>
</div>
</div>
<!-- ====================================================
PRIORITY BANNER
==================================================== -->
<div id="priority-banner">
<span class="priority-text" id="priority-text">Waiting for priority...</span>
<button class="btn-priority-pass" onclick="passPriority()">Pass Priority</button>
<button class="btn-close" style="font-size:13px;" onclick="closePriorityWindow()">&#x2715;</button>
</div>
<!-- ====================================================
LEGAL ACTIONS BAR
==================================================== -->
<div id="legal-actions-bar">
<!-- Populated by renderLegalActionsBar() -->
</div>
<!-- ====================================================
UNDO HISTORY PANEL
==================================================== -->
<div id="undo-history-panel">
<!-- Populated by renderUndoHistory() -->
</div>
<!-- ====================================================
MANA COLOR PICKER (dynamically appended) -->
<!-- Zone card context menu (dynamically appended) -->
<!-- SBA alert panel (dynamically appended) -->
<!-- Set-life dialog (dynamically appended) -->
<!-- ====================================================
SCRIPTS
Load order matters:
1. precons.js — static PRECON_DATA fallback
2. engine.js — game state & core logic
3. ai-bridge.js — backend connector & DeckSourceClient
4. precon-loader.js — startGameWithDecks() + loadPreconDeckFromDck()
5. player-panels.js — player panel rendering
6. ui.js — setup screen, deck picker, UI wiring
==================================================== -->
<script src="precons.js?v=20260403_0300"></script>
<script src="engine.js?v=20260403_0300"></script>
<script src="ai-bridge.js?v=20260403_0300"></script>
<script src="precon-loader.js?v=20260403_0300"></script>
<script src="player-panels.js?v=20260403_0300"></script>
<script src="ui.js?v=20260403_0300"></script>
<script>
// ============================================================
// STARTUP & BRIDGE FUNCTIONS
// ============================================================
// Token creation from modal
function createTokenFromModal() {
if (!gameState) return;
const name = document.getElementById('token-name').value.trim() || '1/1 Token';
const typeLine = document.getElementById('token-type').value.trim() || 'Creature';
const pt = document.getElementById('token-pt').value.trim() || '1/1';
const count = parseInt(document.getElementById('token-count').value) || 1;
const keywords = document.getElementById('token-keywords').value.trim();
const playerIdx = parseInt(document.getElementById('token-player-select').value) || 0;
const parts = pt.split('/');
const power = parts[0] ? parts[0].trim() : '1';
const toughness = parts[1] ? parts[1].trim() : '1';
for (let i = 0; i < count; i++) {
addCardToBattlefield(name, '', typeLine, playerIdx, undefined, undefined,
keywords ? keywords.replace(/,/g, ' ') : '',
{ power, toughness, pt, isToken: true });
}
addLogEntry('Created ' + count + 'x <strong>' + name + '</strong> token(s) for Player ' + (playerIdx + 1));
closeTokenModal();
}
// Pass priority
function passPriority() {
addLogEntry('Priority passed.');
closePriorityWindow();
}
// Open precon browser (stub - opens deck modal for current player)
function openPreconBrowser() {
if (gameState) openDeckModal(gameState.currentPlayerIndex);
}
// Open collection modal (stub)
function openCollectionModal() { showToast('Collection tracker available in full version.'); }
// Scry dialog stub
function showScryDialog(playerId) {
if (!gameState) return;
const player = gameState.players[playerId];
if (!player || player.zones.library.length === 0) {
addLogEntry('!! ' + (player ? player.name : 'Player') + ' has no cards to scry.');
return;
}
const count = Math.min(parseInt(prompt('Scry how many? (1-7)', '1') || '1'), 7, player.zones.library.length);
if (isNaN(count) || count < 1) return;
const topCards = player.zones.library.slice(0, count);
const names = topCards.map(c => c.name || '?').join(', ');
addLogEntry('<strong>' + player.name + '</strong> scrys ' + count + ': [' + names + ']');
}
// Mill dialog stub
function showMillDialog(playerId) {
if (!gameState) return;
const count = parseInt(prompt('Mill how many cards?', '1') || '1');
if (isNaN(count) || count < 1) return;
millCards(playerId, count);
}
// Become monarch
function becomeMonarch(playerId) {
if (!gameState) return;
gameState.monarchPlayerIndex = playerId;
const player = gameState.players[playerId];
addLogEntry('&#x1F451; <strong>' + (player ? player.name : 'Player') + '</strong> becomes the Monarch!');
updateMonarchBadges();
}
// Empower the life counter right-click on the quadrant labels
document.addEventListener('DOMContentLoaded', function() {
// Wire right-click on life values to set-life dialog
for (var i = 0; i < 4; i++) {
(function(idx) {
var lifeEl = document.getElementById('life-' + idx);
if (lifeEl) {
lifeEl.addEventListener('contextmenu', function(e) {
e.preventDefault();
showSetLifeDialog(idx);
});
}
})(i);
}
// Life counter right-click on quadrant buttons too
// (already handled via inline onclick in HTML)
// Auto-detect Commander-AI-Lab backend on page load
if (typeof AIBridge !== 'undefined') {
AIBridge.init().then(function(status) {
var indicator = document.getElementById('backend-status-indicator');
if (indicator) {
indicator.textContent = status.connected
? (status.modelLoaded ? '\u{1F9E0} ML Backend Connected' : '\u26a1 Backend Connected (no ML model)')
: '\u{1F4E1} Local Mode (no backend)';
indicator.className = 'backend-status ' +
(status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected');
}
});
}
});
</script>
</body>
</html>
+270
View File
@@ -0,0 +1,270 @@
// player-panels.js — Enhanced Player Panel Component
// Provides collapsible panels with life, commander damage, counters, and emblems.
'use strict';
/**
* Create an enhanced player panel widget for a quadrant.
* Replaces the basic life counter with a full tracking panel.
*
* @param {object} player - Player object from gameState
* @param {number} quadrant - 1-4 (matches CSS class p1-p4)
* @param {boolean} isActive - Whether this is the active player
*/
function createEnhancedPlayerPanel(player, quadrant, isActive) {
var panel = document.createElement('div');
panel.className = 'enhanced-player-panel' +
(isActive ? ' active-turn' : '') +
(player.eliminated ? ' eliminated' : '');
panel.id = 'enhanced-panel-' + player.id;
panel.setAttribute('data-player-id', player.id);
// Track collapse state
var collapseKey = 'panel-collapsed-' + player.id;
var isCollapsed = localStorage.getItem(collapseKey) === 'true';
// ── Header Row ──────────────────────────────────────────
var headerHtml =
'<div class="epp-header" onclick="togglePlayerPanel(' + player.id + ')">' +
'<span class="epp-collapse-arrow">' + (isCollapsed ? '\u25b8' : '\u25be') + '</span>' +
'<div class="epp-name-dot" style="background:' + player.color + '"></div>' +
'<span class="epp-name">' + player.name + '</span>' +
(player.isAI ? '<span class="epp-ai-badge">AI</span>' : '') +
'<span class="epp-life-badge">' +
'\u2665 ' + player.life +
(player.life < player.startingLife
? ' / ' + player.startingLife
: '') +
'</span>' +
'</div>';
// ── Life Counter Row ────────────────────────────────────
var lifeHtml =
'<div class="epp-life-row">' +
'<button class="epp-life-btn minus" onclick="adjustLife(' + player.id + ',-1)" ' +
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',-5)" ' +
'title="Left: -1 | Right: -5">\u2212</button>' +
'<div class="epp-life-value" id="epp-life-' + player.id + '" ' +
'onclick="showSetLifeDialog(' + player.id + ')">' +
player.life +
'</div>' +
'<button class="epp-life-btn plus" onclick="adjustLife(' + player.id + ',1)" ' +
'oncontextmenu="event.preventDefault();adjustLife(' + player.id + ',5)" ' +
'title="Left: +1 | Right: +5">+</button>' +
'</div>';
// ── Commander Damage Section ────────────────────────────
var otherPlayers = gameState.players.filter(function(p) {
return p.id !== player.id && !p.eliminated;
});
var cmdDmgHtml =
'<div class="epp-section">' +
'<div class="epp-section-label">Commander Damage</div>' +
'<div class="epp-cmd-dmg-grid">';
otherPlayers.forEach(function(op) {
var dmg = player.commanderDamage[op.id] || 0;
var pct = Math.round((dmg / 21) * 100);
var dangerClass = dmg >= 18 ? 'critical' : dmg >= 10 ? 'warning' : '';
cmdDmgHtml +=
'<div class="epp-cmd-dmg-item ' + dangerClass + '">' +
'<div class="epp-cmd-dmg-bar" style="width:' + pct + '%"></div>' +
'<div class="epp-cmd-dmg-dot" style="background:' + op.color + '"></div>' +
'<span class="epp-cmd-dmg-name">' + op.name + '</span>' +
'<span class="epp-cmd-dmg-value">' + dmg + '/21</span>' +
'<button class="epp-counter-btn" ' +
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',1)" ' +
'title="Add commander damage">+</button>' +
'<button class="epp-counter-btn" ' +
'onclick="event.stopPropagation();adjustCommanderDamage(' + player.id + ',' + op.id + ',-1)" ' +
'title="Remove commander damage">\u2212</button>' +
'</div>';
});
cmdDmgHtml += '</div></div>';
// ── Counters Section ────────────────────────────────────
var poison = player.poison || 0;
var energy = player.energy || 0;
var experience = player.experience || 0;
var rad = player.rad || 0;
var countersHtml =
'<div class="epp-section">' +
'<div class="epp-section-label">Counters</div>' +
'<div class="epp-counters-grid">' +
_buildCounterWidget('Poison', '\u2620', poison, 10,
'adjustPoison(' + player.id + ',1)',
'adjustPoison(' + player.id + ',-1)',
poison >= 8 ? 'critical' : poison >= 5 ? 'warning' : '') +
_buildCounterWidget('Energy', '\u269b', energy, 0,
'adjustEnergy(' + player.id + ',1)',
'adjustEnergy(' + player.id + ',-1)', '') +
_buildCounterWidget('Experience', '\u2605', experience, 0,
'adjustExperience(' + player.id + ',1)',
'adjustExperience(' + player.id + ',-1)', '') +
_buildCounterWidget('Rad', '\u2622', rad, 0,
'adjustRad(' + player.id + ',1)',
'adjustRad(' + player.id + ',-1)', '') +
'</div>' +
'</div>';
// ── Emblems Section ─────────────────────────────────────
var emblems = player.emblems || [];
var emblemsHtml =
'<div class="epp-section">' +
'<div class="epp-section-label">Emblems</div>' +
'<div class="epp-emblems">';
if (emblems.length === 0) {
emblemsHtml += '<span class="epp-no-emblems">None</span>';
} else {
emblems.forEach(function(emb, idx) {
emblemsHtml +=
'<div class="epp-emblem-chip" title="' + emb.name + '">' +
'<span class="epp-emblem-name">' + emb.name + '</span>' +
'<button class="epp-emblem-remove" ' +
'onclick="event.stopPropagation();removeEmblem(' + player.id + ',' + idx + ')">\u00d7</button>' +
'</div>';
});
}
emblemsHtml +=
'<button class="epp-add-emblem-btn" ' +
'onclick="event.stopPropagation();showAddEmblemDialog(' + player.id + ')" ' +
'title="Add an emblem">+ Emblem</button>' +
'</div>' +
'</div>';
// ── Assemble Panel ──────────────────────────────────────
var bodyHtml =
'<div class="epp-body" id="epp-body-' + player.id + '" ' +
'style="' + (isCollapsed ? 'display:none;' : '') + '">' +
lifeHtml + cmdDmgHtml + countersHtml + emblemsHtml +
'</div>';
panel.innerHTML = headerHtml + bodyHtml;
return panel;
}
function _buildCounterWidget(label, icon, value, threshold, onInc, onDec, dangerClass) {
return '<div class="epp-counter ' + dangerClass + '">' +
'<div class="epp-counter-icon">' + icon + '</div>' +
'<div class="epp-counter-info">' +
'<span class="epp-counter-value">' + value + '</span>' +
'<span class="epp-counter-label">' + label + '</span>' +
'</div>' +
'<div class="epp-counter-btns">' +
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onInc + '">+</button>' +
'<button class="epp-counter-btn" onclick="event.stopPropagation();' + onDec + '">\u2212</button>' +
'</div>' +
'</div>';
}
function togglePlayerPanel(playerId) {
var body = document.getElementById('epp-body-' + playerId);
var panel = document.getElementById('enhanced-panel-' + playerId);
if (!body || !panel) return;
var isCollapsed = body.style.display === 'none';
body.style.display = isCollapsed ? '' : 'none';
var arrow = panel.querySelector('.epp-collapse-arrow');
if (arrow) arrow.textContent = isCollapsed ? '\u25be' : '\u25b8';
localStorage.setItem('panel-collapsed-' + playerId, !isCollapsed);
}
// Counter adjustment functions
function adjustEnergy(playerId, amount) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
player.energy = Math.max(0, (player.energy || 0) + amount);
addLogEntry('<strong>' + player.name + '</strong> energy: ' + player.energy);
renderEnhancedPanels();
}
function adjustExperience(playerId, amount) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
player.experience = Math.max(0, (player.experience || 0) + amount);
addLogEntry('<strong>' + player.name + '</strong> experience: ' + player.experience);
renderEnhancedPanels();
}
function adjustRad(playerId, amount) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
player.rad = Math.max(0, (player.rad || 0) + amount);
addLogEntry('<strong>' + player.name + '</strong> rad counters: ' + player.rad);
renderEnhancedPanels();
}
function showAddEmblemDialog(playerId) {
var name = prompt('Enter emblem name (e.g., "Sorin, Vengeful Bloodlord"):');
if (!name || !name.trim()) return;
if (!gameState) return;
var player = gameState.players[playerId];
if (!player) return;
if (!player.emblems) player.emblems = [];
player.emblems.push({ name: name.trim() });
addLogEntry('<strong>' + player.name + '</strong> gained emblem: ' + name.trim());
renderEnhancedPanels();
}
function removeEmblem(playerId, idx) {
if (!gameState) return;
var player = gameState.players[playerId];
if (!player || !player.emblems) return;
var removed = player.emblems.splice(idx, 1);
if (removed.length > 0) {
addLogEntry('<strong>' + player.name + '</strong> lost emblem: ' + removed[0].name);
}
renderEnhancedPanels();
}
/**
* Re-render all enhanced player panels in their quadrant positions.
*/
function renderEnhancedPanels() {
if (!gameState) return;
for (var i = 0; i < gameState.players.length; i++) {
var player = gameState.players[i];
var quadrant = i + 1;
var areaEl = document.getElementById('player-area-' + quadrant);
if (!areaEl) continue;
// Find or create the enhanced panel container
var container = areaEl.querySelector('.epp-container');
if (!container) {
container = document.createElement('div');
container.className = 'epp-container';
// Insert before the zone row
var zoneRow = areaEl.querySelector('.zone-row');
if (zoneRow) {
areaEl.insertBefore(container, zoneRow);
} else {
areaEl.appendChild(container);
}
}
var isActive = gameState.currentPlayerIndex === i;
var panel = createEnhancedPlayerPanel(player, quadrant, isActive);
container.innerHTML = '';
container.appendChild(panel);
// Hide the old life counter (we have our own now)
var oldLifeCounter = areaEl.querySelector('.life-counter');
if (oldLifeCounter) oldLifeCounter.style.display = 'none';
// Hide old poison badge (we have counters section)
var oldPoison = areaEl.querySelector('.poison-badge');
if (oldPoison) oldPoison.style.display = 'none';
}
}
+299
View File
@@ -0,0 +1,299 @@
/**
* 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';
// In-memory Scryfall image cache: cardName -> { imageUrl, typeLine, oracleText, manaCost, power, toughness }
const _scryfallCache = {};
// Scryfall collection endpoint — accepts up to 75 card identifiers per call
const SCRYFALL_COLLECTION_URL = 'https://api.scryfall.com/cards/collection';
// ============================================================
// 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();
// Kick off image enrichment for all players in the background (non-blocking)
enrichAllZonesWithScryfall();
}
// ============================================================
// 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();
for (const player of gameState.players) {
for (const zone of Object.values(player.zones)) {
for (const card of zone) {
if (card.name && !_scryfallCache[card.name]) nameSet.add(card.name);
}
}
}
if (nameSet.size === 0) return;
const names = Array.from(nameSet);
addLogEntry('\uD83C\uDF0D Fetching card images from Scryfall (' + names.length + ' unique cards)...');
// 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 : '') || '',
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: '', power: '', toughness: '' };
}
}
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;
if (data.power) card.power = data.power;
if (data.toughness) card.toughness = data.toughness;
}
}
}
// 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 && !card.imageUrl) {
card.imageUrl = data.imageUrl || '';
card.typeLine = data.typeLine || card.typeLine;
card.oracleText = data.oracleText || card.oracleText;
card.manaCost = data.manaCost || card.manaCost;
}
}
}
}
// 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();
}
/**
* 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));
}
+167
View File
@@ -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}
];
+3439
View File
File diff suppressed because it is too large. Load diff
+2309
View File
File diff suppressed because it is too large. Load diff
+203 -82
View File
@@ -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]]:
"""
@@ -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
+32 -17
View File
@@ -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)
@@ -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
+24 -1
View File
@@ -167,6 +167,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 +259,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 +272,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
+48 -13
View File
@@ -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")
+153 -10
View File
@@ -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
@@ -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)
+2 -1
View File
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 300
clock: int = 5000
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
@@ -170,6 +170,7 @@ class CoachGoalsRequest(BaseModel):
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
class DeckGenV3SubstituteRequest(BaseModel):
card_name: str
substitute_name: str
+154 -131
View File
@@ -1,131 +1,154 @@
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
anthropic_api_key: str = ""
CFG = Config()
# Pre-populate from env so ASGI mode (uvicorn lab_api:app) works without main()
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
def validate_forge_config() -> None:
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
if not CFG.forge_dir:
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
return
base = Path(CFG.forge_dir)
a = base / "forge-gui" / "res" / "cardsfolder"
b = base / "res" / "cardsfolder"
if not a.is_dir() and not b.is_dir():
log.warning(
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
" %s\n %s\n"
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
CFG.forge_dir, a, b
)
else:
resolved = a if a.is_dir() else b
log.info("Forge cardsfolder confirmed at %s", resolved)
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
import asyncio as _asyncio
active_batches: dict[str, BatchState] = {}
active_batches_lock = _asyncio.Lock()
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META.clear()
COMMANDER_META.update(json.load(f))
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META.clear()
COMMANDER_META.update(BUILTIN_COMMANDERS)
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
# ── .env bootstrap ────────────────────────────────────────────
# Load .env automatically so plain `python script.py` and bare uvicorn
# both pick up FORGE_DIR, PRECON_DIR, etc. without a manual load_dotenv
# call at every entry point. override=False means real env vars always win.
try:
from dotenv import load_dotenv as _load_dotenv
_here = Path(__file__).resolve().parent
for _candidate in [_here, _here.parent, _here.parent.parent]:
_env_file = _candidate / ".env"
if _env_file.is_file():
_load_dotenv(dotenv_path=_env_file, override=False)
break
except ImportError:
pass # python-dotenv not installed; rely on real environment variables
# ─────────────────────────────────────────────────────────────
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
anthropic_api_key: str = ""
CFG = Config()
# Pre-populate from environment so uvicorn/ASGI mode works without CLI args.
# CLI args (lab_api.py main()) will override these if provided.
CFG.anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY", "")
CFG.ximilar_api_key = os.environ.get("XIMILAR_API_KEY", "")
CFG.pplx_api_key = os.environ.get("PPLX_API_KEY", "")
CFG.forge_jar = os.environ.get("FORGE_JAR", "")
CFG.forge_dir = os.environ.get("FORGE_DIR", "")
CFG.lab_jar = os.environ.get("LAB_JAR", "")
# PRECON_DIR / PRECON_DECKS_DIR are aliases -- prefer PRECON_DIR
CFG.precon_dir = os.environ.get("PRECON_DIR") or os.environ.get("PRECON_DECKS_DIR", "")
CFG.forge_decks_dir = os.environ.get("FORGE_DECKS_DIR", "")
def validate_forge_config() -> None:
"""Warn clearly at startup if FORGE_DIR is misconfigured."""
if not CFG.forge_dir:
log.warning("FORGE_DIR not set -- Forge card enrichment disabled. "
"Set FORGE_DIR to your forge-repo root or forge-gui directory.")
return
base = Path(CFG.forge_dir)
a = base / "forge-gui" / "res" / "cardsfolder"
b = base / "res" / "cardsfolder"
if not a.is_dir() and not b.is_dir():
log.warning(
"FORGE_DIR=%s is set but cardsfolder not found at:\n"
" %s\n %s\n"
"Check that FORGE_DIR points to forge-repo root or forge-gui/.",
CFG.forge_dir, a, b
)
else:
resolved = a if a.is_dir() else b
log.info("Forge cardsfolder confirmed at %s", resolved)
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
import asyncio as _asyncio
active_batches: dict[str, BatchState] = {}
active_batches_lock = _asyncio.Lock()
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META.clear()
COMMANDER_META.update(json.load(f))
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META.clear()
COMMANDER_META.update(BUILTIN_COMMANDERS)
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced — Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive — attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control — defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo — ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange — flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
-4
View File
@@ -9,7 +9,6 @@ httpx>=0.24.0
websockets>=11.0
openai>=1.0.0
aiosqlite>=0.19.0
aiofiles>=23.0.0
# Anthropic Claude API
anthropic>=0.40.0
@@ -20,9 +19,6 @@ beautifulsoup4>=4.12.0
ollama>=0.2.0
scrython>=2.0.0
# Phase 0: image download script
tqdm>=4.65.0
# Embeddings
# RAG vector store
-94
View File
@@ -1,94 +0,0 @@
"""
Phase 0 — routes/card_art.py
FastAPI router for card art endpoints.
Endpoints:
GET /card-art/{scryfall_id}
Local-first image redirect.
- If local file exists: redirect to /static/card-images/{id}_front.jpg
- If not: redirect to Scryfall CDN (no 404, always returns an image)
GET /card-art/{scryfall_id}/back
Same as above but for the back face of DFCs.
GET /api/card-image-info/{scryfall_id}
JSON info: art_url, art_url_back, has_back, is_local
GET /api/card-image-by-name/{name}
Look up by card name (case-insensitive). Returns image info JSON.
See issue #167
"""
from __future__ import annotations
import urllib.parse
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import RedirectResponse
from commander_ai_lab.cards.image_cache import get_image_cache
router = APIRouter(tags=["card-art"])
@router.get("/card-art/{scryfall_id}")
async def get_card_art(scryfall_id: str):
"""
Redirect to the front face image for a card.
Always returns a redirect — never a 404.
Local cache first, Scryfall CDN fallback.
"""
cache = get_image_cache()
result = cache.lookup_by_id(scryfall_id)
if result:
return RedirectResponse(url=result.front_url, status_code=302)
# Ultimate fallback: redirect to CDN directly
d1 = scryfall_id[0] if scryfall_id else "0"
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
cdn = f"https://cards.scryfall.io/normal/front/{d1}/{d2}/{scryfall_id}.jpg"
return RedirectResponse(url=cdn, status_code=302)
@router.get("/card-art/{scryfall_id}/back")
async def get_card_art_back(scryfall_id: str):
"""
Redirect to the back face image for a DFC.
Falls back to CDN back URL if not locally cached.
"""
cache = get_image_cache()
result = cache.lookup_by_id(scryfall_id)
if result and result.back_url:
return RedirectResponse(url=result.back_url, status_code=302)
d1 = scryfall_id[0] if scryfall_id else "0"
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
cdn = f"https://cards.scryfall.io/normal/back/{d1}/{d2}/{scryfall_id}.jpg"
return RedirectResponse(url=cdn, status_code=302)
@router.get("/api/card-image-info/{scryfall_id}")
async def get_card_image_info(scryfall_id: str):
"""
Return JSON image info for a card by Scryfall ID.
Always returns a result (CDN fallback if not cached locally).
"""
cache = get_image_cache()
result = cache.lookup_by_id(scryfall_id)
if result:
return result.to_dict()
raise HTTPException(status_code=404, detail=f"Card not found: {scryfall_id}")
@router.get("/api/card-image-by-name/{name}")
async def get_card_image_by_name(name: str):
"""
Return JSON image info for a card by name (case-insensitive).
Uses the SQLite index. Returns 404 if name not found.
"""
decoded_name = urllib.parse.unquote(name)
cache = get_image_cache()
result = cache.lookup_by_name(decoded_name)
if result:
return result.to_dict()
raise HTTPException(status_code=404, detail=f"Card name not found: {decoded_name}")
+209 -65
View File
@@ -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)
_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()
],
}
+114 -7
View File
@@ -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
@@ -39,6 +41,7 @@ from services.deck_service import parse_dck_file, _load_deck_cards_by_name
from services.forge_runner import (
build_java_command, run_batch_subprocess, _run_deepseek_batch_thread,
)
from services.draw_game_analyzer import summarize_draw_games
from services.logging import log, log_batch
router = APIRouter(tags=["lab"])
@@ -57,6 +60,32 @@ def _sanitize_floats(obj):
return obj
def _inject_win_type(data: dict, win_type_override: str) -> None:
"""Stamp winType on every per-game dict that doesn't already have one.
Called by get_result() after loading the JSON from disk.
- Uses setdefault so DeepSeek per-game values (already set in forge_runner.py)
are never overwritten.
- Also stamps metadata.winType for a quick batch-level summary field.
- win_type_override comes from state.win_type_override (Java path) or
defaults to "Combat" if the attribute is absent (DeepSeek / old batches).
"""
if not win_type_override or win_type_override == "Combat":
# Still stamp Combat as default so the field is always present
win_type_override = "Combat"
# Batch-level metadata field
meta = data.get("metadata")
if isinstance(meta, dict):
meta.setdefault("winType", win_type_override)
# Per-game dicts nested under data["decks"][N]["games"][M]
for deck in data.get("decks", []):
for game in deck.get("games", []):
if isinstance(game, dict):
game.setdefault("winType", win_type_override)
@router.post("/api/lab/start", response_model=StartResponse)
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
"""Start a new batch simulation run."""
@@ -196,6 +225,19 @@ async def get_result(batchId: Optional[str] = None):
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
raise HTTPException(500, f"Failed to read result file: {e}")
# ── Inject winType into every game dict (fix #175) ──────────────────────
win_type_override = getattr(state, "win_type_override", "Combat")
_inject_win_type(data, win_type_override)
# ── Inject draw-game summary (fix #176) ─────────────────────────────────
# draw_game_snapshots is populated live by forge_runner._run_process_blocking
# from [DRAW] [PARSE-SUMMARY] lines. summarize_draw_games() aggregates them
# into a single dict with perDeck breakdowns and a likelyCause field so the
# dashboard can immediately show which deck is causing timeout loops.
draw_snapshots = getattr(state, "draw_game_snapshots", [])
data["drawGames"] = summarize_draw_games(draw_snapshots)
data = _sanitize_floats(data)
return JSONResponse(content=data)
@@ -269,31 +311,31 @@ async def list_history():
AI_PROFILES = {
"default": {
"name": "default",
"description": "Balanced \u2014 Forge's default AI behavior",
"description": "Balanced — Forge's default AI behavior",
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
},
"aggro": {
"name": "aggro",
"description": "Aggressive \u2014 attacks early, prioritizes damage",
"description": "Aggressive — attacks early, prioritizes damage",
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
},
"control": {
"name": "control",
"description": "Control \u2014 defensive, removal-heavy, card advantage",
"description": "Control — defensive, removal-heavy, card advantage",
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
},
"combo": {
"name": "combo",
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
"description": "Combo — ramps, digs for pieces, assembles combos",
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
},
"midrange": {
"name": "midrange",
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
"description": "Midrange — flexible, strong board presence, value-oriented",
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
},
@@ -428,6 +470,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):
@@ -469,5 +578,3 @@ async def start_batch_deepseek(request: FastAPIRequest, background_tasks: Backgr
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
'engine': 'deepseek',
})
+37 -7
View File
@@ -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)}
+37 -4
View File
@@ -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.
@@ -47,7 +72,11 @@ async def install_precon(req: dict):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
try:
shutil.copy2(str(src), str(dst))
except OSError as e:
log.error(f"Failed to copy deck file {file_name}: {e}")
raise HTTPException(500, f"Failed to copy deck file: {e}")
deck_name = file_name.replace(".dck", "")
return {
@@ -78,9 +107,13 @@ async def install_precons_batch(req: dict):
results.append({"fileName": file_name, "installed": False, "error": "not found"})
continue
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
try:
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
except OSError as e:
log.error(f"Failed to copy deck file {file_name}: {e}")
results.append({"fileName": file_name, "installed": False, "error": str(e)})
return {"results": results}
-135
View File
@@ -1,135 +0,0 @@
#!/usr/bin/env python3
"""
Phase 0 — build_image_index.py
Builds (or rebuilds) the SQLite card image index at:
data/card_image_index.db
Schema:
card_images(
scryfall_id TEXT PRIMARY KEY,
name TEXT NOT NULL, -- card name, lowercased for lookup
name_raw TEXT NOT NULL, -- original casing
front_path TEXT, -- relative path from project root
back_path TEXT, -- DFC back face, NULL for single-faced
has_back INTEGER DEFAULT 0 -- 1 if DFC
)
CREATE INDEX idx_name ON card_images(name);
Usage:
python scripts/build_image_index.py
python scripts/build_image_index.py --rebuild
"""
from __future__ import annotations
import argparse
import json
import logging
import sqlite3
import urllib.request
from pathlib import Path
log = logging.getLogger("build_image_index")
SCRYFALL_BULK_API = "https://api.scryfall.com/bulk-data/default-cards"
PROJECT_ROOT = Path(__file__).parent.parent
IMAGE_DIR = PROJECT_ROOT / "static" / "card-images"
DB_PATH = PROJECT_ROOT / "data" / "card_image_index.db"
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS card_images (
scryfall_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_raw TEXT NOT NULL,
front_path TEXT,
back_path TEXT,
has_back INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_name ON card_images(name);
"""
def fetch_bulk_cards() -> list[dict]:
log.info("Fetching Scryfall bulk manifest...")
with urllib.request.urlopen(SCRYFALL_BULK_API, timeout=30) as r:
meta = json.loads(r.read())
url = meta["download_uri"]
log.info("Downloading bulk card data...")
with urllib.request.urlopen(url, timeout=120) as r:
return json.loads(r.read())
def build_rows(cards: list[dict]) -> list[tuple]:
"""
For each card, check if the image file(s) exist locally.
Builds rows for both single-faced and DFC cards.
Tokens are included if they have image files.
"""
rows: list[tuple] = []
for card in cards:
scryfall_id = card.get("id", "")
name_raw = card.get("name", "")
if not scryfall_id or not name_raw:
continue
name = name_raw.lower().strip()
front_file = IMAGE_DIR / f"{scryfall_id}_front.jpg"
back_file = IMAGE_DIR / f"{scryfall_id}_back.jpg"
front_path = f"static/card-images/{scryfall_id}_front.jpg" if front_file.exists() else None
back_path = f"static/card-images/{scryfall_id}_back.jpg" if back_file.exists() else None
has_back = 1 if back_path else 0
rows.append((scryfall_id, name, name_raw, front_path, back_path, has_back))
return rows
def write_db(rows: list[tuple], rebuild: bool) -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
if rebuild and DB_PATH.exists():
DB_PATH.unlink()
log.info("Deleted existing database for rebuild.")
conn = sqlite3.connect(str(DB_PATH))
try:
conn.executescript(CREATE_TABLE_SQL)
conn.executemany(
"""
INSERT OR REPLACE INTO card_images
(scryfall_id, name, name_raw, front_path, back_path, has_back)
VALUES (?, ?, ?, ?, ?, ?)
""",
rows
)
conn.commit()
log.info("Indexed %d cards into %s", len(rows), DB_PATH)
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="Build SQLite card image index.")
parser.add_argument("--rebuild", action="store_true",
help="Drop and rebuild the database from scratch")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s"
)
cards = fetch_bulk_cards()
log.info("Processing %d card entries...", len(cards))
rows = build_rows(cards)
log.info("Found %d cards with local images.", len(rows))
write_db(rows, args.rebuild)
log.info("Index build complete: %s", DB_PATH)
if __name__ == "__main__":
main()
-181
View File
@@ -1,181 +0,0 @@
#!/usr/bin/env python3
"""
Phase 0 — download_card_images.py
Bulk-downloads all MTG card images from Scryfall's default_cards bulk export.
Stores images at: static/card-images/{scryfall_id}_front.jpg
static/card-images/{scryfall_id}_back.jpg (DFCs only)
Usage:
python scripts/download_card_images.py
python scripts/download_card_images.py --image-size normal
python scripts/download_card_images.py --workers 8 --resume
Scryfall bulk image domain has no rate limits, so no sleep is needed.
See: https://scryfall.com/docs/api/bulk-data
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
try:
from tqdm import tqdm
except ImportError:
tqdm = None
log = logging.getLogger("download_card_images")
SCRYFALL_BULK_API = "https://api.scryfall.com/bulk-data/default-cards"
DEFAULT_IMAGE_SIZE = "normal" # 488x680px — ideal for Unity card rendering
DEFAULT_WORKERS = 6
# Scryfall requires a descriptive User-Agent — returns HTTP 400 without it.
# See: https://scryfall.com/docs/api#making-requests
USER_AGENT = "CommanderAILab/1.0 (https://github.com/KoalaTrapLord/commander-ai-lab)"
PROJECT_ROOT = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_ROOT / "static" / "card-images"
def _open(url: str, timeout: int = 30):
"""urllib.request.urlopen with required Scryfall User-Agent header."""
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
return urllib.request.urlopen(req, timeout=timeout)
def fetch_bulk_download_url(image_size: str) -> str:
"""Get the Scryfall bulk data download URL for default_cards."""
log.info("Fetching Scryfall bulk data manifest...")
with _open(SCRYFALL_BULK_API, timeout=30) as resp:
meta = json.loads(resp.read())
return meta["download_uri"]
def load_bulk_data(download_url: str) -> list[dict]:
"""Stream-download and parse the bulk JSON (can be ~100MB)."""
log.info("Downloading bulk card data from %s ...", download_url)
with _open(download_url, timeout=120) as resp:
cards = json.loads(resp.read())
log.info("Loaded %d card entries.", len(cards))
return cards
def get_image_jobs(cards: list[dict], image_size: str, resume: bool) -> list[tuple[str, str]]:
"""
Build a list of (url, dest_path) tuples to download.
Handles:
- Single-faced cards: {scryfall_id}_front.jpg
- Double-faced cards (DFCs): {scryfall_id}_front.jpg + {scryfall_id}_back.jpg
- Tokens: uses image_uris if present, else skips gracefully
"""
jobs: list[tuple[str, str]] = []
for card in cards:
scryfall_id = card.get("id", "")
if not scryfall_id:
continue
# Single-faced or meld cards
if "image_uris" in card:
url = card["image_uris"].get(image_size)
if url:
dest = str(OUTPUT_DIR / f"{scryfall_id}_front.jpg")
if not resume or not Path(dest).exists():
jobs.append((url, dest))
# Double-faced cards (DFCs): modal, transform, adventure, etc.
elif "card_faces" in card:
for idx, face in enumerate(card["card_faces"]):
face_uris = face.get("image_uris", {})
url = face_uris.get(image_size)
if url:
side = "front" if idx == 0 else "back"
dest = str(OUTPUT_DIR / f"{scryfall_id}_{side}.jpg")
if not resume or not Path(dest).exists():
jobs.append((url, dest))
return jobs
def download_image(url: str, dest: str) -> tuple[bool, str]:
"""Download a single image. Returns (success, dest)."""
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=30) as resp:
with open(dest, "wb") as f:
f.write(resp.read())
return True, dest
except Exception as exc:
log.warning("Failed to download %s -> %s: %s", url, dest, exc)
return False, dest
def run_downloads(jobs: list[tuple[str, str]], workers: int) -> tuple[int, int]:
"""Download all jobs concurrently. Returns (success_count, fail_count)."""
success = 0
fail = 0
iterator = tqdm(total=len(jobs), unit="img", desc="Downloading") if tqdm else None
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(download_image, url, dest): dest for url, dest in jobs}
for future in as_completed(futures):
ok, _ = future.result()
if ok:
success += 1
else:
fail += 1
if iterator:
iterator.update(1)
if iterator:
iterator.close()
return success, fail
def main():
parser = argparse.ArgumentParser(description="Bulk download Scryfall card images.")
parser.add_argument("--image-size", default=DEFAULT_IMAGE_SIZE,
choices=["small", "normal", "large", "png", "art_crop", "border_crop"],
help="Image size to download (default: normal = 488x680px)")
parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS,
help="Concurrent download threads (default: 6)")
parser.add_argument("--resume", action="store_true",
help="Skip images that already exist on disk")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s"
)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
log.info("Output directory: %s", OUTPUT_DIR)
download_url = fetch_bulk_download_url(args.image_size)
cards = load_bulk_data(download_url)
jobs = get_image_jobs(cards, args.image_size, args.resume)
log.info("Jobs to download: %d (resume=%s)", len(jobs), args.resume)
if not jobs:
log.info("Nothing to do. All images already present.")
return
success, fail = run_downloads(jobs, args.workers)
log.info("Done. Success: %d Failed: %d Total: %d", success, fail, len(jobs))
if fail > 0:
log.warning("%d images failed — re-run with --resume to retry.", fail)
sys.exit(1)
if __name__ == "__main__":
main()
+35
View File
@@ -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`);
-4
View File
@@ -1,4 +0,0 @@
# Phase 6 - run_self_play.py
# Headless self-play runner: all 4 players AI-driven, no Unity needed
# Usage: python scripts/run_self_play.py --num-games 500 --deck-pool-path data/decks/
# See issue #173
+157 -27
View File
@@ -13,6 +13,16 @@ from services.database import _get_db_conn, _row_to_dict
log_collect = logging.getLogger("lab.collection")
# Repo root = two levels up from this file (services/deck_service.py)
_REPO_ROOT = Path(__file__).parent.parent
# Repo-relative deck dirs always searched as fallbacks (zero-config support)
_BUILTIN_DECK_DIRS = [
_REPO_ROOT / "precon-decks",
_REPO_ROOT / "sample-decks",
_REPO_ROOT / "imported-decks",
]
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
_TYPE_TARGETS = {
@@ -197,7 +207,7 @@ def _save_profile_to_dck(profile: dict) -> Path:
safe_name = "imported_deck"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
save_dir = str(_REPO_ROOT / "imported-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")
@@ -205,35 +215,155 @@ def _save_profile_to_dck(profile: dict) -> Path:
return out_path
def _find_dck_file(deck_name: str) -> Optional[Path]:
"""Search known deck directories for a .dck file matching deck_name.
Resolution order:
1. CFG.precon_dir (explicit CLI/env config)
2. CFG.forge_decks_dir (Forge user decks)
3. FORGE_DIR/res/decks/commander (Forge bundled commander decks)
4. Repo-relative fallbacks: precon-decks/, sample-decks/, imported-decks/
(always searched so precon decks work with zero configuration)
Within each directory tries:
a. Exact stem match (Elven_Empire.dck)
b. Case-insensitive fuzzy match stripping underscores/spaces
"""
search_dirs: list[Path] = []
# CFG-configured paths (set via CLI args)
if CFG.precon_dir and os.path.isdir(CFG.precon_dir):
search_dirs.append(Path(CFG.precon_dir))
if CFG.forge_decks_dir and os.path.isdir(CFG.forge_decks_dir):
search_dirs.append(Path(CFG.forge_decks_dir))
if CFG.forge_dir:
forge_cmdr = Path(CFG.forge_dir) / "res" / "decks" / "commander"
if forge_cmdr.is_dir():
search_dirs.append(forge_cmdr)
# Repo-relative fallbacks -- always active so precon decks need no config
for builtin_dir in _BUILTIN_DECK_DIRS:
if builtin_dir.is_dir() and builtin_dir not in search_dirs:
search_dirs.append(builtin_dir)
target_norm = deck_name.lower().replace("_", "").replace(" ", "")
for d in search_dirs:
exact = d / f"{deck_name}.dck"
if exact.exists():
return exact
try:
for f in d.iterdir():
if f.suffix.lower() == ".dck":
if f.stem.lower().replace("_", "").replace(" ", "") == target_norm:
log_collect.info(f"[DeckService] Fuzzy .dck match: '{deck_name}' -> '{f}'")
return f
except OSError:
pass
return None
def _load_deck_cards_from_dck(dck_path: Path) -> list:
"""Parse a .dck file and return card dicts enriched with Forge card data.
Calls lookup_forge_card() for each card so that type_line, oracle_text,
cmc, mana_cost, power, toughness, and keywords are populated from the
Forge cardsfolder cache. This ensures Card.is_land() / is_creature()
work correctly when a deck is loaded via the .dck fallback path (i.e.
the deck is not yet in the SQLite DB).
The Forge cache is lazy-loaded and module-level, so there is no
per-game overhead after the first deck load.
"""
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card # lazy to avoid circular import
parsed = parse_dck_file(str(dck_path))
cards = []
def _make_card(card_name: str, qty: int, is_commander: int) -> dict:
forge = lookup_forge_card(card_name)
power = ""
toughness = ""
if forge and "/" in forge.pt:
parts = forge.pt.split("/", 1)
power = parts[0].strip()
toughness = parts[1].strip()
return {
"card_name": card_name,
"name": card_name,
"quantity": qty,
"is_commander": is_commander,
"type_line": forge.type_line if forge else "",
"oracle_text": forge.oracle_text if forge else "",
"cmc": forge.cmc if forge else 0,
"mana_cost": forge.mana_cost if forge else "",
"power": power,
"toughness": toughness,
"keywords": json.dumps(forge.keywords) if forge else "[]",
"color_identity": "[]",
}
for card_name, qty in parsed.get("commanders", {}).items():
cards.append(_make_card(card_name, qty, 1))
for card_name, qty in parsed.get("mainboard", {}).items():
cards.append(_make_card(card_name, qty, 0))
enriched = sum(1 for c in cards if c["type_line"])
log_collect.info(
"[DeckService] .dck load: %d cards, %d enriched from Forge cache (%s)",
len(cards), enriched, dck_path.name,
)
return cards
def _load_deck_cards_by_name(deck_name: str) -> list:
"""Load cards from a deck by name, returning list of card dicts."""
"""Load cards from a deck by name, returning list of card dicts.
Resolution order:
1. SQLite DB (decks built/imported via the Deck Builder UI)
2. .dck file fallback -- searches precon_dir, forge_decks_dir,
FORGE_DIR/res/decks/commander, and repo-relative precon-decks/
sample-decks/ so precon decks work without any configuration.
"""
# -- 1. DB lookup --
conn = _get_db_conn()
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
if not deck_row:
return []
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
if deck_row:
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
# -- 2. .dck file fallback --
dck_path = _find_dck_file(deck_name)
if dck_path:
log_collect.info(
f"[DeckService] '{deck_name}' not in DB -- loading from .dck file: {dck_path}"
)
return _load_deck_cards_from_dck(dck_path)
log_collect.warning(f"[DeckService] Deck '{deck_name}' not found in DB or any .dck search path.")
return []
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
+152
View File
@@ -0,0 +1,152 @@
"""
services/draw_game_analyzer.py
==============================
Analyses draw-game snapshots captured by forge_runner._run_process_blocking.
A "draw game" is any game that hit the Forge per-game wall-clock timeout
instead of ending via a win condition. Forge emits a line like:
[DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200 \
seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0 \
seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
This module:
1. Parses those lines out of the log into DrawGameSnapshot objects.
2. Aggregates them to identify which deck most often leads at timeout.
3. Exposes a summary dict suitable for embedding in the batch result JSON.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional
# ─── regex for the Forge DRAW summary line ────────────────────────────────────
# Example:
# [DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200
# seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0
# seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
_DRAW_LINE_RE = re.compile(
r'\[DRAW\].*?\[PARSE-SUMMARY\]'
r'.*?game=(\d+)'
r'.*?turns=(\d+)'
r'.*?time_ms=(\d+)'
r'((?:\s+seat\d+=\S+)*)'
, re.IGNORECASE
)
_SEAT_RE = re.compile(r'seat(\d+)=([^:]+):life=([-\d]+)', re.IGNORECASE)
@dataclass
class DrawGameSnapshot:
game_id: int
turns: int
time_ms: int
# seat_index -> {deck_name, life}
seats: Dict[int, dict] = field(default_factory=dict)
@property
def leading_deck(self) -> Optional[str]:
"""Name of the deck with the highest life at timeout (None if tie/empty)."""
if not self.seats:
return None
best = max(self.seats.values(), key=lambda s: s["life"])
return best["deck_name"]
@property
def leading_life(self) -> int:
if not self.seats:
return 0
return max(s["life"] for s in self.seats.values())
def parse_draw_snapshots(log_lines: list[str]) -> list[DrawGameSnapshot]:
"""
Scan raw log lines for [DRAW] [PARSE-SUMMARY] entries and return
a list of DrawGameSnapshot objects, one per draw game.
"""
snapshots: list[DrawGameSnapshot] = []
for line in log_lines:
m = _DRAW_LINE_RE.search(line)
if not m:
continue
game_id = int(m.group(1))
turns = int(m.group(2))
time_ms = int(m.group(3))
seats_str = m.group(4) or ""
seats: Dict[int, dict] = {}
for sm in _SEAT_RE.finditer(seats_str):
seat_idx = int(sm.group(1))
deck_name = sm.group(2).strip()
life = int(sm.group(3))
seats[seat_idx] = {"deck_name": deck_name, "life": life}
snapshots.append(DrawGameSnapshot(game_id=game_id, turns=turns, time_ms=time_ms, seats=seats))
return snapshots
def summarize_draw_games(snapshots: list[DrawGameSnapshot]) -> dict:
"""
Aggregate draw snapshots into a summary dict for embedding in batch JSON.
Returns::
{
"count": 156,
"avgTurns": 27.4,
"avgTimeMs": 61800,
"perDeck": [
{
"deckName": "AdaptiveEnchantment",
"drawLeadCount": 89, # times this deck led at timeout
"drawLeadPct": 57.1,
"avgLifeAtTimeout": 42.3,
},
...
],
"likelyCause": "AdaptiveEnchantment" # deck most often leading at timeout
}
"""
if not snapshots:
return {"count": 0, "avgTurns": 0, "avgTimeMs": 0, "perDeck": [], "likelyCause": None}
total = len(snapshots)
avg_turns = round(sum(s.turns for s in snapshots) / total, 1)
avg_time_ms = round(sum(s.time_ms for s in snapshots) / total)
# Accumulate per-deck stats across all draw games
deck_lead_count: Dict[str, int] = {}
deck_life_sum: Dict[str, float] = {}
deck_life_count: Dict[str, int] = {}
for snap in snapshots:
leader = snap.leading_deck
if leader:
deck_lead_count[leader] = deck_lead_count.get(leader, 0) + 1
for seat in snap.seats.values():
dn = seat["deck_name"]
deck_life_sum[dn] = deck_life_sum.get(dn, 0.0) + seat["life"]
deck_life_count[dn] = deck_life_count.get(dn, 0) + 1
all_decks = set(deck_life_count.keys()) | set(deck_lead_count.keys())
per_deck = []
for dn in sorted(all_decks):
lead_cnt = deck_lead_count.get(dn, 0)
avg_life = round(deck_life_sum.get(dn, 0) / max(deck_life_count.get(dn, 1), 1), 1)
per_deck.append({
"deckName": dn,
"drawLeadCount": lead_cnt,
"drawLeadPct": round(lead_cnt / total * 100, 1),
"avgLifeAtTimeout": avg_life,
})
per_deck.sort(key=lambda x: x["drawLeadCount"], reverse=True)
likely_cause = per_deck[0]["deckName"] if per_deck else None
return {
"count": total,
"avgTurns": avg_turns,
"avgTimeMs": avg_time_ms,
"perDeck": per_deck,
"likelyCause": likely_cause,
}
+164 -7
View File
@@ -168,6 +168,62 @@ def reset_java17_cache():
_JAVA17_PATH = None
# ── Deck path resolution ──────────────────────────────────────────────────────
def _resolve_deck_path(deck_name: str) -> str:
"""Resolve a deck name to its full .dck path in CFG.precon_dir.
Resolution order:
1. Already an absolute path with .dck extension — return as-is.
2. Exact filename match in precon_dir (e.g. "Abzan_Armor.dck").
3. camelCase → snake_case conversion (e.g. "AbzanArmor" → "Abzan_Armor.dck").
4. Space → underscore normalization.
5. Case-insensitive fuzzy match stripping underscores/spaces.
6. Fallback: return raw name and let the JAR fail with a clear error.
"""
# Already a resolved path
if deck_name.endswith('.dck') and os.path.isfile(deck_name):
return deck_name
precon_dir = CFG.precon_dir
if not precon_dir or not os.path.isdir(precon_dir):
log.warning(f"[DeckResolver] precon_dir not set or missing — passing raw name '{deck_name}' to JAR")
return deck_name
# 1. Exact match
exact = os.path.join(precon_dir, f"{deck_name}.dck")
if os.path.isfile(exact):
return exact
# 2. camelCase → snake_case (AbzanArmor → Abzan_Armor)
snake = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', '_', deck_name)
snake_path = os.path.join(precon_dir, f"{snake}.dck")
if os.path.isfile(snake_path):
return snake_path
# 3. Space → underscore (e.g. "Abzan Armor" → "Abzan_Armor.dck")
spaced = deck_name.replace(' ', '_')
spaced_path = os.path.join(precon_dir, f"{spaced}.dck")
if os.path.isfile(spaced_path):
return spaced_path
# 4. Case-insensitive fuzzy: strip underscores/spaces, compare lowercased stems
target = deck_name.lower().replace('_', '').replace(' ', '')
try:
for fname in os.listdir(precon_dir):
if fname.lower().endswith('.dck'):
stem = fname[:-4].lower().replace('_', '').replace(' ', '')
if stem == target:
resolved = os.path.join(precon_dir, fname)
log.info(f"[DeckResolver] Fuzzy match: '{deck_name}' → '{fname}'")
return resolved
except OSError as e:
log.warning(f"[DeckResolver] Could not list precon_dir '{precon_dir}': {e}")
log.warning(f"[DeckResolver] Deck not found: '{deck_name}' (searched {precon_dir}) — passing raw name to JAR")
return deck_name
def build_java_command(
decks: list, num_games: int, threads: int, seed: Optional[int],
clock: int, output_path: str, use_learned_policy: bool = False,
@@ -191,9 +247,11 @@ def build_java_command(
'--clock', str(clock),
'--output', output_path,
]
# Map deck list to --deck1 .. --deck4
# Map deck list to --deck1 .. --deck4, resolving names to full .dck paths
for i, d in enumerate(decks[:4], start=1):
cmd += [f'--deck{i}', str(d)]
resolved = _resolve_deck_path(str(d))
log.info(f"[DeckResolver] deck{i}: '{d}' → '{resolved}'")
cmd += [f'--deck{i}', resolved]
if seed is not None:
cmd += ['--seed', str(seed)]
if use_learned_policy:
@@ -210,10 +268,50 @@ def build_java_command(
return cmd
# ── Draw-game life-at-timeout parser ─────────────────────────────────────────
# Forge emits a summary line for each game that hits the wall-clock timeout:
#
# [DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200
# seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0
# seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
#
# We capture these live so they're available before the process finishes.
_DRAW_SUMMARY_RE = re.compile(
r'\[DRAW\].*?\[PARSE-SUMMARY\]'
r'.*?game=(\d+)'
r'.*?turns=(\d+)'
r'.*?time_ms=(\d+)'
r'((?:\s+seat\d+=\S+)*)'
, re.IGNORECASE
)
_SEAT_LIFE_RE = re.compile(r'seat(\d+)=([^:]+):life=([-\d]+)', re.IGNORECASE)
def _parse_draw_summary_line(line: str) -> Optional[dict]:
"""Return a draw-game snapshot dict if line matches, else None."""
m = _DRAW_SUMMARY_RE.search(line)
if not m:
return None
seats = {}
for sm in _SEAT_LIFE_RE.finditer(m.group(4) or ""):
seat_idx = int(sm.group(1))
seats[seat_idx] = {"deck_name": sm.group(2).strip(), "life": int(sm.group(3))}
return {
"game_id": int(m.group(1)),
"turns": int(m.group(2)),
"time_ms": int(m.group(3)),
"seats": seats,
}
def _run_process_blocking(state: BatchState, cmd: list):
"""Run Forge subprocess, parse progress, generate deck reports."""
"""Run Forge subprocess, parse progress, collect draw-game snapshots."""
log.info(f"Running: {' '.join(cmd)}")
# Forge must run with forge_dir as cwd so it can find res/ relative to itself
forge_cwd = CFG.forge_dir if CFG.forge_dir and os.path.isdir(CFG.forge_dir) else None
log.info(f"[ForgeRunner] cwd={forge_cwd}")
# Ensure Forge subprocesses use Java 17
env = os.environ.copy()
java17 = get_java17()
@@ -226,7 +324,7 @@ def _run_process_blocking(state: BatchState, cmd: list):
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env,
text=True, bufsize=1, env=env, cwd=forge_cwd,
)
state.process = proc
except Exception as e:
@@ -237,10 +335,15 @@ def _run_process_blocking(state: BatchState, cmd: list):
_register_batch(state.batch_id, state, proc)
# ── Live stdout parsing ───────────────────────────────────────────
draw_snapshots: list = []
for line in proc.stdout:
_touch_batch(state.batch_id)
line = line.rstrip()
state.log_lines.append(line)
# Progress counters
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
if gm:
state.completed_games = int(gm.group(1))
@@ -249,19 +352,46 @@ def _run_process_blocking(state: BatchState, cmd: list):
if pm:
state.sims_per_sec = float(pm.group(1))
# ── Draw-game capture (fix #176) ──────────────────────────────
snap = _parse_draw_summary_line(line)
if snap:
draw_snapshots.append(snap)
proc.wait()
_unregister_batch(state.batch_id)
elapsed = (_datetime.now() - state.start_time).total_seconds() * 1000
state.elapsed_ms = int(elapsed)
# Persist draw snapshots on state for routes/lab.py to embed in JSON
state.draw_game_snapshots = draw_snapshots
if draw_snapshots:
log.info(
f"[DrawGames] Captured {len(draw_snapshots)} draw-game snapshots "
f"for batch {state.batch_id}"
)
if proc.returncode != 0:
state.running = False
# Include last few log lines in error for easier diagnosis
tail = ' | '.join(state.log_lines[-5:]) if state.log_lines else '(no output)'
state.error = f"Exit code {proc.returncode} — last output: {tail}"
else:
state.running = False
state.completed_games = state.total_games
# ── Post-process: detect alternate win conditions in the Java log ──
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_log
state.win_type_override = detect_win_type_from_log(state.log_lines)
if state.win_type_override != "Combat":
log.info(f"[WinType] Alternate win detected in batch {state.batch_id}: {state.win_type_override}")
except Exception as _wcp_err:
log.debug(f"[WinType] Parser import failed (non-fatal): {_wcp_err}")
state.win_type_override = "Combat"
async def run_batch_subprocess(
state: BatchState,
@@ -269,7 +399,7 @@ async def run_batch_subprocess(
num_games: int,
threads: int,
seed: Optional[int] = None,
clock: int = 6000,
clock: int = 3000,
output_path: str = "results",
use_learned_policy: bool = False,
policy_style: str = "midrange",
@@ -278,7 +408,12 @@ async def run_batch_subprocess(
ai_think_time_ms: int = -1,
max_queue_depth: int = -1,
):
"""Async wrapper that runs Forge subprocess in executor."""
"""Async wrapper that runs Forge subprocess in executor.
clock default lowered from 6000 → 3000 (30s per-game wall limit for batch
mode) to reduce draw-game wall time wasted on timeouts (fix #176).
Interactive games pass an explicit req.clock override so they are unaffected.
"""
try:
policy_server = f"http://localhost:{CFG.port}"
cmd = build_java_command(
@@ -344,6 +479,7 @@ def _run_deepseek_batch_thread(
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_cards
state.log_lines.append('[DeepSeek Batch] Initializing DeepSeek brain...')
brain = _get_deepseek_brain()
@@ -503,6 +639,27 @@ def _run_deepseek_batch_thread(
)
gd = result.to_dict()
gd['gameNumber'] = g + 1
# ── Alternate win detection (DeepSeek path) ──────────────
try:
winner_seat = result.winner_seat
if winner_seat >= 0:
winner_bf = getattr(result, '_winner_battlefield', None)
if winner_bf is not None:
win_type = detect_win_type_from_cards(winner_bf)
else:
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_log
win_type = detect_win_type_from_log(
[str(entry) for entry in (result.game_log or [])]
)
result.win_type = win_type
gd['winType'] = win_type
if win_type != 'Combat':
log.info(f'[WinType] {deck_name} game {g+1}: {win_type} win detected')
except Exception as _wt_err:
log.debug(f'[WinType] Detection failed for game {g+1} (non-fatal): {_wt_err}')
gd.setdefault('winType', 'Combat')
deck_stats['games'].append(gd)
if result.winner == 0:
+6
View File
@@ -354,11 +354,16 @@ class GameResult:
- players: list of PlayerResult, one per seat
- winner_seat: index of the winning player (-1 = draw)
- win_type: how the game was won — "Combat", "Combo", "Drain", "Mill",
"Poison", or "CmdrDmg". Defaults to "Combat".
Set by win_condition_parser.detect_win_type_from_cards() (Python engine)
or win_condition_parser.detect_win_type_from_log() (Java/Forge engine).
- Backward-compat properties player_a_stats / player_b_stats delegate to players[0] / players[1]
"""
winner_seat: int = -1
turns: int = 0
win_type: str = "Combat" # populated by win_condition_parser
players: list[PlayerResult] = field(default_factory=list)
game_log: list = field(default_factory=list)
@@ -428,6 +433,7 @@ class GameResult:
# Legacy keys (UI expects these)
"winner": self.winner_seat,
"turns": self.turns,
"winType": self.win_type,
# New N-player keys (Java BatchResult schema)
"winningSeat": self.winner_seat,
"playerResults": [p.to_dict() for p in self.players],
@@ -0,0 +1,155 @@
"""
Commander AI Lab — Alternate Win Condition Parser
==================================================
Scans Forge log lines or oracle text for non-combat "you win the game"
triggers and returns a win type category string.
Used by:
- services/forge_runner.py (Java batch path: post-process log_lines)
- src/commander_ai_lab/sim/deepseek_engine.py (DeepSeek path: check oracle text)
- src/commander_ai_lab/sim/engine.py (Python sim path: check oracle text)
"""
from __future__ import annotations
import re
from typing import Optional
# ---------------------------------------------------------------------------
# Alternate win card registry
# ---------------------------------------------------------------------------
# Maps lowercase card name -> win category.
# Categories: "Combo", "Drain", "Mill", "Poison"
# "Combat" is the default and should never appear here.
ALTERNATE_WIN_CARDS: dict[str, str] = {
# Spell-based
"approach of the second sun": "Combo",
"thassa's oracle": "Combo",
"jace, wielder of mysteries": "Combo",
"laboratory maniac": "Combo",
"leveler": "Combo", # pairs with Lab Man / Thassa
"gleemax": "Combo",
"coalition victory": "Combo",
"helix pinnacle": "Combo",
"mayael's aria": "Combo",
"mechanized production": "Combo",
"happily ever after": "Combo",
"simic ascendancy": "Combo",
"azor's elocutors": "Combo",
"chance encounter": "Combo",
"darksteel reactor": "Combo",
"hellkite tyrant": "Combo",
"mortal kombat": "Combo",
"revel in riches": "Drain",
"test of endurance": "Combo",
"felidar sovereign": "Combo",
"celestial convergence": "Combo",
"barren glory": "Combo",
"maze's end": "Combo",
"new horizons": "Combo",
"thespian's stage": "Combo", # copying Maze's End
# Poison / Infect
"phyrexian unlife": "Poison",
"poisoned tips": "Poison",
# Mill
"milling": "Mill", # generic tag
"the fall of lord karsus": "Mill",
}
# ---------------------------------------------------------------------------
# Forge log scanner
# ---------------------------------------------------------------------------
# Matches lines like:
# [COMBAT-LINE] Resolve Stack: Approach of the Second Sun ... you win the game.
# [OUTCOME-LINE] ... you win the game ...
# Game Result: Player won (Approach of the Second Sun)
_YOU_WIN_RE = re.compile(
r'you win the game',
re.IGNORECASE,
)
# Extracts a card name from the line so we can look it up in ALTERNATE_WIN_CARDS.
# Forge typically formats these as "Resolve Stack: <CardName> ..." or
# "Player won via <CardName>".
_CARD_NAME_RE = re.compile(
r'(?:Resolve Stack|via|won by|triggered by|winning card)[:\s]+([A-Za-z\'\,\.\- ]+)',
re.IGNORECASE,
)
def detect_win_type_from_log(log_lines: list[str]) -> str:
"""
Scan a list of Forge stdout log lines for alternate win condition triggers.
Returns the win category ("Combo", "Drain", "Mill", "Poison") if an
alternate win card is detected, otherwise returns "Combat".
Scans *all* lines so the last detected alternate win takes precedence
(handles edge cases where the log mentions multiple near-wins).
"""
result = "Combat"
for line in log_lines:
if not _YOU_WIN_RE.search(line):
continue
# Try to extract the card name responsible
m = _CARD_NAME_RE.search(line)
if m:
card_name = m.group(1).strip().rstrip('.').lower()
# Exact lookup first
if card_name in ALTERNATE_WIN_CARDS:
result = ALTERNATE_WIN_CARDS[card_name]
continue
# Partial / substring match for long card names
for known, category in ALTERNATE_WIN_CARDS.items():
if known in card_name or card_name in known:
result = category
break
else:
# No card name found but "you win the game" is present —
# scan the full line for any known card name
line_lower = line.lower()
for known, category in ALTERNATE_WIN_CARDS.items():
if known in line_lower:
result = category
break
return result
# ---------------------------------------------------------------------------
# Oracle text scanner (Python / DeepSeek engine path)
# ---------------------------------------------------------------------------
def detect_win_type_from_cards(cards_on_battlefield: list) -> str:
"""
Inspect a player's battlefield (list of Card objects) for alternate win
permanents. Returns the win category or "Combat".
Checks:
1. Card name against ALTERNATE_WIN_CARDS (fastest)
2. oracle_text for "you win the game" as a safety net
"""
for card in cards_on_battlefield:
name_lower = getattr(card, 'name', '').lower()
if name_lower in ALTERNATE_WIN_CARDS:
return ALTERNATE_WIN_CARDS[name_lower]
oracle = getattr(card, 'oracle_text', '') or ''
if 'you win the game' in oracle.lower():
# Unknown alternate win card — still not Combat
return "Combo"
return "Combat"
def detect_win_type_from_oracle(oracle_text: str, card_name: str = "") -> str:
"""
Simple oracle text probe used when we have a resolved spell/ability
but not a full battlefield list.
"""
if card_name:
lookup = card_name.strip().lower()
if lookup in ALTERNATE_WIN_CARDS:
return ALTERNATE_WIN_CARDS[lookup]
if 'you win the game' in (oracle_text or '').lower():
return "Combo"
return "Combat"
File diff suppressed because it is too large. Load diff
@@ -30,9 +30,13 @@ import java.util.regex.*;
* waits on {@code humanActionQueue}, converts the action to the
* macro-action string expected by /api/policy/decide, and injects it
* via POST /api/policy/override so the policy server returns it.
* 4. Publishes a full state snapshot to the WebSocket frontend after every
* 4. AI seats (1+) are intercepted at every Priority event: the session
* calls policyClient.decide() with the current state snapshot and
* immediately POSTs the result to /api/policy/override so Forge uses
* the learned policy instead of its built-in AI.
* 5. Publishes a full state snapshot to the WebSocket frontend after every
* meaningful Forge output event.
* 5. Falls back to the synthetic stub loop if Forge cannot be started
* 6. Falls back to the synthetic stub loop if Forge cannot be started
* (e.g., missing JAR, wrong Java version).
*
* Zone sync model:
@@ -120,6 +124,12 @@ public class GameSession {
private volatile boolean gameOver = false;
private volatile long elapsedMs = 0;
// Tracks whether policy was injected for the current priority window
// to avoid double-injecting on duplicate log lines.
private volatile int lastPolicyInjectionTurn = -1;
private volatile String lastPolicyInjectionPhase = "";
private volatile int lastPolicyInjectionSeat = -1;
private final int numSeats;
private final int[] life;
private final int[] poison;
@@ -309,6 +319,10 @@ public class GameSession {
watchdog.setDaemon(true);
watchdog.start();
// Connect to policy server before the game loop begins
boolean policyConnected = policyClient.connect();
log("[Session] Policy server connection: " + (policyConnected ? "OK" : "UNAVAILABLE — AI uses Forge built-in"));
// Emit initial state so UI shows a board immediately
onStateChange.accept(buildStateSnapshot());
@@ -326,19 +340,29 @@ public class GameSession {
reader.setDaemon(true);
reader.start();
// Main loop: drain line queue and service human input
// Main loop: drain line queue and service human/AI input
while (!gameOver && running) {
String line = lineQueue.poll(200, TimeUnit.MILLISECONDS);
if (line != null) {
lastActivityEpoch.incrementAndGet();
boolean changed = parseForgeLine(line);
if (changed) onStateChange.accept(buildStateSnapshot());
// Human seat
if (awaitingHumanInput) handleHumanTurn(onStateChange);
} else if (forgeDone.get() && lineQueue.isEmpty()) {
break;
}
}
// Flush online learning tuples and submit reward if game finished
if (gameOver && policyClient.isServerAvailable()) {
double reward = winningSeat != null && winningSeat != 0 ? 1.0 : -0.5;
policyClient.submitReward("game-" + seed, reward);
policyClient.flushCollectedTuples();
log(String.format("[Session] Submitted game reward=%.1f and flushed %d tuples",
reward, policyClient.getPendingTuples()));
}
watchdog.interrupt();
try { forgeProcess.waitFor(10, TimeUnit.SECONDS); } catch (InterruptedException ignored) {}
@@ -432,8 +456,28 @@ public class GameSession {
// Priority
Matcher mPrio = P_PRIORITY.matcher(line);
if (mPrio.find()) {
priorityPlayer = clampSeat(Integer.parseInt(mPrio.group(1)) - 1);
int seat = clampSeat(Integer.parseInt(mPrio.group(1)) - 1);
priorityPlayer = seat;
changed = true;
// ── Policy injection for AI seats ────────────────────────
// Forge doesn't honour --policyServer natively, so we intercept
// every AI priority window here and push the policy decision to
// /api/policy/override before Forge's next read.
if (seat != 0 && policyClient.isServerAvailable()) {
// Deduplicate: only inject once per (turn, phase, seat) window
boolean alreadyInjected =
lastPolicyInjectionTurn == turnNumber
&& lastPolicyInjectionPhase.equals(phase)
&& lastPolicyInjectionSeat == seat;
if (!alreadyInjected) {
lastPolicyInjectionTurn = turnNumber;
lastPolicyInjectionPhase = phase;
lastPolicyInjectionSeat = seat;
injectAiPolicyDecision(seat);
}
}
}
// Life totals
@@ -535,6 +579,49 @@ public class GameSession {
return changed;
}
// ----------------------------------------------------------------
// AI policy injection (real Forge IPC path)
// ----------------------------------------------------------------
/**
* Ask the Python policy server what action AI seat {@code seat} should
* take given the current game state, then POST it to /api/policy/override
* so that the next time Forge calls /api/policy/decide for this seat it
* receives the pre-computed answer without an extra round-trip.
*
* This is the core wiring that was missing: Forge's sim mode does not
* natively call --policyServer, so we intercept every Priority log line
* and drive the decision from the Java side instead.
*/
private void injectAiPolicyDecision(int seat) {
try {
PolicyClient.PolicyDecision decision =
policyClient.decide(GSON.toJson(buildStateSnapshot()), seat);
if (decision == null || decision.action() == null) {
log(String.format("[Policy] seat=%d turn=%d phase=%s → null decision, skipping override",
seat, turnNumber, phase));
return;
}
String action = decision.action();
log(String.format("[Policy] seat=%d turn=%d phase=%s → %s (conf=%.2f)",
seat, turnNumber, phase, action, decision.confidence()));
// Push the decision to the override endpoint so Forge's next
// /api/policy/decide call for this seat returns it immediately.
JsonObject body = new JsonObject();
body.addProperty("seat", seat);
body.addProperty("action", action);
httpPost(policyServerUrl + "/api/policy/override", body.toString());
} catch (Exception e) {
// Non-fatal: Forge falls back to its built-in AI for this window
log(String.format("[Policy] seat=%d override failed (%s) — Forge built-in takes over",
seat, e.getMessage()));
}
}
// ----------------------------------------------------------------
// Human turn handling
// ----------------------------------------------------------------
+61 -25
View File
@@ -7,33 +7,63 @@ REM Starts the Python FastAPI server + serves the web UI.
REM Prerequisites:
REM - Python 3.10+ with: pip install fastapi uvicorn
REM - Java 17+
REM - commander-ai-lab built: mvn package "-DskipTests"
REM - Forge built: mvn package "-DskipTests"
REM
REM Edit the paths below to match your system.
REM All paths are resolved RELATIVE to this script's location.
REM No hardcoded drive letters — works on any machine.
REM ═══════════════════════════════════════════════════════════
REM ── Your Forge paths (EDIT THESE) ──────────────────────────
set FORGE_JAR=D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
set FORGE_DIR=D:\ForgeCommander\forge-repo
set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
REM ── Lab JAR (auto-detected from target/) ─────────────────────
REM ── Resolve root from script location (portable, any drive) ─
set SCRIPT_DIR=%~dp0
set FORGE_COMMANDER_ROOT=%SCRIPT_DIR%..
REM ── Forge paths (auto-resolved, no hardcoded drive) ─────────
set FORGE_JAR=%FORGE_COMMANDER_ROOT%\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
set FORGE_DIR=%FORGE_COMMANDER_ROOT%\forge-repo
set FORGE_PROFILE=%FORGE_COMMANDER_ROOT%\forge-repo\forge-gui-desktop\target\res\forge.profile.properties
REM ── Deck paths: all inside forge-repo for full portability ──
set FORGE_USER_DATA=%FORGE_COMMANDER_ROOT%\forge-repo\userdata
set FORGE_DECKS_DIR=%FORGE_USER_DATA%\decks
set PRECON_DECKS_DIR=%SCRIPT_DIR%precon-decks
REM ── Lab server port ──────────────────────────────────────────
set LAB_PORT=8080
REM ── API Keys (set via environment variables or .env file) ──
REM If not already set in your environment, create a .env file
REM in this directory (see .env.example) or set them manually:
REM set XIMILAR_API_KEY=your-ximilar-key-here
REM set ANTHROPIC_API_KEY=your-anthropic-api-key-here
REM
REM Load from .env file if it exists and vars aren't already set
if not defined XIMILAR_API_KEY (
if exist "%~dp0.env" (
for /f "usebackq tokens=1,* delims==" %%A in ("%~dp0.env") do (
if exist "%SCRIPT_DIR%.env" (
for /f "usebackq tokens=1,* delims==" %%A in ("%SCRIPT_DIR%.env") do (
if "%%A"=="XIMILAR_API_KEY" set XIMILAR_API_KEY=%%B
if "%%A"=="ANTHROPIC_API_KEY" set ANTHROPIC_API_KEY=%%B
)
)
)
REM ═══════════════════════════════════════════════════════════
REM Write forge.profile.properties so Forge resolves decks from
REM forge-repo\userdata instead of %APPDATA%\Forge.
REM Backslashes are doubled as required by .properties format.
REM This is rewritten every launch so it always matches the
REM current machine's absolute path.
REM ═══════════════════════════════════════════════════════════
mkdir "%FORGE_USER_DATA%\decks\commander" 2>nul
REM Build escaped paths for .properties file
set _UD=%FORGE_USER_DATA%
set _DD=%FORGE_DECKS_DIR%
setlocal enabledelayedexpansion
set _UD_ESC=!_UD:\=\\!
set _DD_ESC=!_DD:\=\\!
(echo userDir=!_UD_ESC!) > "%FORGE_PROFILE%"
(echo decksDir=!_DD_ESC!) >> "%FORGE_PROFILE%"
endlocal
REM ── Copy precon .dck files into Forge's commander deck folder
REM (idempotent — safe to run every launch)
xcopy /Y /Q "%PRECON_DECKS_DIR%\*.dck" "%FORGE_DECKS_DIR%\commander\" >nul 2>&1
echo Synced precon decks to Forge commander folder.
echo.
echo ╔══════════════════════════════════════════════════╗
echo ║ Commander AI Lab — Launcher ║
@@ -42,12 +72,17 @@ echo.
echo Forge JAR: %FORGE_JAR%
echo Forge Dir: %FORGE_DIR%
echo Decks Dir: %FORGE_DECKS_DIR%
echo Precon Dir: %PRECON_DECKS_DIR%
echo Port: %LAB_PORT%
echo Ximilar: Configured
if defined ANTHROPIC_API_KEY (
echo Claude Opus: Configured
if defined XIMILAR_API_KEY (
echo Ximilar: Configured
) else (
echo Claude Opus: Not configured ^(set ANTHROPIC_API_KEY env var for AI features^)
echo Ximilar: Not configured
)
if defined ANTHROPIC_API_KEY (
echo Claude: Configured
) else (
echo Claude: Not configured ^(set ANTHROPIC_API_KEY for AI features^)
)
echo.
@@ -69,15 +104,14 @@ if %ERRORLEVEL% neq 0 (
if not exist "%FORGE_JAR%" (
echo ERROR: Forge JAR not found at: %FORGE_JAR%
echo Run Forge build first:
echo cd D:\ForgeCommander\forge-repo
echo cd %FORGE_DIR%
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
pause
exit /b 1
)
REM ── Check if lab_api.py exists ─────────────────────────────
if not exist "%~dp0lab_api.py" (
echo ERROR: lab_api.py not found in %~dp0
if not exist "%SCRIPT_DIR%lab_api.py" (
echo ERROR: lab_api.py not found in %SCRIPT_DIR%
pause
exit /b 1
)
@@ -98,18 +132,20 @@ echo Press Ctrl+C to stop.
echo.
if defined ANTHROPIC_API_KEY (
python "%~dp0lab_api.py" ^
python "%SCRIPT_DIR%lab_api.py" ^
--forge-jar "%FORGE_JAR%" ^
--forge-dir "%FORGE_DIR%" ^
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
--precon-dir "%PRECON_DECKS_DIR%" ^
--port %LAB_PORT% ^
--ximilar-key "%XIMILAR_API_KEY%" ^
--anthropic-key "%ANTHROPIC_API_KEY%"
) else (
python "%~dp0lab_api.py" ^
python "%SCRIPT_DIR%lab_api.py" ^
--forge-jar "%FORGE_JAR%" ^
--forge-dir "%FORGE_DIR%" ^
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
--precon-dir "%PRECON_DECKS_DIR%" ^
--port %LAB_PORT% ^
--ximilar-key "%XIMILAR_API_KEY%"
)
-2
View File
@@ -1,2 +0,0 @@
# Phase 0: Card images downloaded here by scripts/download_card_images.py
# ~1.5-2GB at normal size
+18 -2
View File
@@ -835,8 +835,8 @@ const AiLab = (() => {
// Commander art from Scryfall — use imageUrl if backend provided it,
// otherwise build a fuzzy lookup URL from the commander name
const artUrl = p.imageUrl
|| `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
const scryfallFallback = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
const artUrl = p.imageUrl || scryfallFallback;
return `
<div class="lab-precon-card ${installClass}" onclick="AiLab.installPrecon('${escapeAttr(p.fileName)}')">
@@ -854,6 +854,22 @@ const AiLab = (() => {
</div>
</div>`;
}).join('');
loadPreconArt(gridEl);
}
async function loadPreconArt(gridEl) {
const imgs = gridEl.querySelectorAll('img.precon-card-art[data-commander]');
for (const img of imgs) {
const commander = img.dataset.commander;
if (!commander) continue;
try {
const res = await fetch(`${API_BASE}/card-image/${encodeURIComponent(commander)}`);
if (res.ok) {
const data = await res.json();
if (data.art_url) { img.src = data.art_url; img.style.display = ''; }
}
} catch (_) { /* keep Scryfall fallback */ }
}
}
async function installPrecon(fileName) {
-2
View File
@@ -1,2 +0,0 @@
# Phase 4: Unity TCGEngine project lives here
# See issue #171