Compare commits

..
Author SHA1 Message Date
KoalaTrapLord 2c67222de1 fix(lobby): wire OnConfirm to POST /api/game/start, save game_id to PlayerPrefs
- OnConfirm() now calls ApiClient.PostGameStart() with 4-seat config
- On success: saves BattleSeats + GameId + WsUrl to PlayerPrefs,
  loads Battleground scene
- On failure: shows inline error label (red), re-enables Confirm button
- Added StartGameRequest/Response POCOs matching routes/game.py schema
- Added _errorLabel [SerializeField] for user-facing error feedback
2026-03-29 05:22:04 -04:00
KoalaTrapLord d8238ec11f feat(game): add routes/game.py + wire lab_api + fix GameWebSocketClient game_id URL
- routes/game.py: POST /api/game/start, POST /api/game/action (new)
- lab_api.py: import + register game_router
- GameWebSocketClient.cs: read game_id from PlayerPrefs, build
  ws://{host}/ws/game/{game_id} — fixes URL mismatch with ws_game.py
2026-03-29 05:17:48 -04:00
KoalaTrapLord 7aed64a660 fix: add PostGameAction to ApiClient + update TODO.md all issues complete
- ApiClient.cs: add PostGameAction() wrapper → POST /api/game/action
  (fixes compile error in HumanActionBar.cs)
- TODO.md: mark issues #145, #146, #147, #149, #150, #151 as ✅
  all 14 battleground issues now committed
2026-03-29 05:09:51 -04:00
KoalaTrapLord e3497a8f74 feat(#149): add GameWebSocketClient with reconnect, ping, and main-thread dispatch 2026-03-28 20:05:35 -04:00
KoalaTrapLord 6a655628c8 feat(#151): add Play Live button to SimulationController 2026-03-28 20:04:42 -04:00
KoalaTrapLord e3d4eb5785 feat(#147 #150 #151): add LobbySetupModal, ZoneViewerModal, EliminationHandler; add Play Live button to SimulationController 2026-03-28 20:03:43 -04:00
KoalaTrapLord abee551ab4 feat(#146): add StackZoneController, StackItemView, and ManaPoolDisplay 2026-03-28 20:02:23 -04:00
KoalaTrapLord 9fdb6d29fb chore: mark issues 1-8 as complete in task tracker 2026-03-28 19:58:00 -04:00
KoalaTrapLord 2c8c6c26bd feat(#144): add TokenModel POCO, TokenPanelController, and TokenRowView 2026-03-28 19:57:00 -04:00
KoalaTrapLord 9ffefd463c feat(#143): add CommanderZoneWidget with art crop, cast counter, and tax display 2026-03-28 19:56:24 -04:00
KoalaTrapLord 4c99957e76 feat(#142): add CardZone and CardView battlefield zone components 2026-03-28 19:56:09 -04:00
KoalaTrapLord e5e25a7553 feat(#141): add TurnOrderBar and TurnOrderPill 2026-03-28 19:55:40 -04:00
KoalaTrapLord 8d56feee80 feat(#140): add PhaseTrackerWidget and PhaseStepButton 2026-03-28 19:55:17 -04:00
KoalaTrapLord 837bb3d6f9 feat(#139): add LifeTrackerWidget, CommanderDamageRow, PoisonCounter 2026-03-28 19:54:56 -04:00
KoalaTrapLord 98b81ceab2 feat(#138): add BattlegroundController scene orchestrator 2026-03-28 19:54:24 -04:00
KoalaTrapLord fae04a9336 feat(#148): add GameStateManager singleton and GameState/SeatState POCOs 2026-03-28 19:54:01 -04:00
KoalaTrapLord 8fd155a925 feat(#143): add LoadArtCrop and LoadCardRaw (RawImage) to ImageCache 2026-03-28 19:53:25 -04:00
KoalaTrapLord 34ce9bfbf7 feat(#142): add [NonSerialized] battleground runtime state to CardModel 2026-03-28 19:53:09 -04:00
KoalaTrapLord 07010a71ed chore: add battleground UI task tracker (2026-03-28) 2026-03-28 19:50:06 -04:00
KoalaTrapLord ac536999eb Update lab_api.pyfeat: wire validate_forge_config into lifespan startup 2026-03-28 14:48:43 -04:00
KoalaTrapLord 2e43511d87 feat: add validate_forge_config() startup check
Validates FORGE_DIR at startup with clear warning if cardsfolder not found.
Checks both forge-gui/res/cardsfolder and res/cardsfolder layouts.
2026-03-28 14:34:50 -04:00
KoalaTrapLord 1bebe38e66 fix: forge_card_loader path resolution + reset_forge_cache helper
- _get_cards_folder() now tries both forge-gui/res/cardsfolder and res/cardsfolder
  so FORGE_DIR can point to either the repo root or the forge-gui subdirectory
- Add reset_forge_cache() to allow hot-reloading without server restart
2026-03-28 14:31:42 -04:00
KoalaTrapLord eca1653024 fix: backfill basic lands on card_entries to guarantee 99 cards
The previous backfill operated on seen_names dict which silently
ignored basics already present (dict keys are unique). Now backfills
directly on card_entries list after Scryfall fetch, incrementing
quantity on existing basics or appending new CardEntry objects.
Distributes shortfall evenly across commander color identity basics.
2026-03-28 00:26:42 -04:00
KoalaTrapLord 08f1cb7581 fix(deckgen): backfill basic lands after cross-category dedup to reach 99 cards 2026-03-28 00:10:52 -04:00
KoalaTrapLord 9d9e8675a9 fix: restore missing # on line 27 section banner in rules.py (SyntaxError on import) 2026-03-27 23:36:02 -04:00
KoalaTrapLord 64526b7bee fix: exclude lands from cmc_buckets in _build_deck_report_from_db (matches deck_service behaviour) 2026-03-27 20:38:49 -04:00
KoalaTrapLord e9732d292f fix: expand _land_kw, hoist to module level, guard has_real_data for cmc=0 lands after Forge lookup 2026-03-27 20:36:22 -04:00
KoalaTrapLord fa5349ea7c fix: basic land name fallback in _build_deck_report_from_db when type_line is missing from collection join 2026-03-27 20:32:15 -04:00
KoalaTrapLord 1c491bc2b0 fix: add Land role detection to _detect_card_roles (fixes basics showing as Unknown) 2026-03-27 20:28:35 -04:00
KoalaTrapLord 81fe5108d2 fix: coach_apply rollback on partial write, 4-space reindent; ws_game hoist inline imports 2026-03-27 20:24:55 -04:00
KoalaTrapLord 61fa37dc48 fix: lifespan indent error, get_running_loop, remove double rag init, register policy routes 2026-03-27 20:22:42 -04:00
KoalaTrapLord 0118950939 fix: add anthropic_api_key to Config with env-var default (fixes AttributeError in ASGI mode) 2026-03-27 20:20:45 -04:00
KoalaTrapLord 22cf23b24d fix: correct query_cards kwarg names in coach_service (n= -> n_results=, colors= -> color_identity=) 2026-03-27 20:17:14 -04:00
KoalaTrapLord 0010b0f394 refactor: RAG improvements — shared card_text, Chroma fallback, staleness monitor (fixes #133, #134, #135) (#136)
* refactor: add shared build_card_text() utility

* refactor: unify _build_document via shared card_text + add is_chroma_available/check_staleness (fixes #133, #134, #135)

* refactor: delegate embeddings._build_card_text to shared utility

* feat: add background RAG staleness monitor to lab_api lifespan (fixes #135)
2026-03-27 16:46:33 -04:00
KoalaTrapLord d0cdafa66a feat(rag): Phase 3 — inject RAG cards into coach prompts (#132)
* feat(rag): add RAG card injection to prompt_template.py

* feat(rag): wire RAG query into coach_service.py

* test(rag): add Phase 3 RAG injection tests
2026-03-27 15:20:49 -04:00
KoalaTrapLord bcf558418a fix(rag): squash all RAG/ChromaDB bugs #123–#130
Fixes critical API contract mismatch, batch_size wiring, lifespan init, failure tracking, build_meta persistence, mana_cost in embeddings, pyproject.toml deps, and adds full test suite.

Closes #123, #124, #125, #126, #127, #128, #129, #130
2026-03-27 13:24:41 -04:00
KoalaTrapLord 61ac349445 fix: re-encode requirements.txt as UTF-8 (fixes pip install failure) 2026-03-27 11:14:34 -04:00
KoalaTrapLord 06ce5cc91a fix(rules): add nonbasic land guard before creature fallback (fixes #118) (#122) 2026-03-27 10:22:05 -04:00
KoalaTrapLord fc2e9d88d5 feat(rag): wire ensure_rag_store into main() startup (#121) 2026-03-27 09:13:03 -04:00
KoalaTrapLord 4b0cd34eda feat(rag): Phase 2 — ChromaDB vector index over bulk card DB (#120)
* feat(rag): Phase 2 — ChromaDB vector index over bulk card DB

* feat(rag): add routes/rag.py with build, search, status endpoints

* Update lab_api.pyfeat(rag): register rag_router in lab_api.py, remove old inline rag_status

* Update requirements.txtfeat(rag): add chromadb>=0.5.0 to requirements.txtfeat(rag): add chromadb to requirements.txt
2026-03-27 08:39:02 -04:00
KoalaTrapLord 83502ce726 feat: Phase 3/5 deep analysis + Quick Digest endpoint (issue #108) (#119)
* feat(coach): Phase 3/5 JSON schema + QUICK_DIGEST prompt (issue #108)

- Append removalCoverage, rampQuality, drawEngineProfile, winConditions,
  antiSynergyFlags, podPresence, tempoAssessment, metaMatchups to schema
- Add 8 new RULES for Phase 3/5 analysis
- Add QUICK_DIGEST_SYSTEM_PROMPT constant
- Add build_quick_system_prompt() function

* feat(coach): Phase 3/5 JSON parsing + run_quick_digest() (issue #108)

* feat(routes): add POST /api/coach/decks/{deck_id}/quick endpoint (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

- Import RemovalCoverage, RampQuality, DrawEngineProfile, WinCondition,
  AntiSynergyFlag, PodPresence, TempoAssessment, MetaMatchup
- Add Phase 3/5 blocks to _populate_session_from_json()
- Add run_quick_digest() method

* feat(ui): Top Priority card + Quick/Deep buttons in coach.html (issue #108)

* feat(coach): Phase 3/5 parsing + run_quick_digest() (issue #108)

* feat(ui): wire Quick Digest button + displayTopPriority() (issue #108)
2026-03-27 07:51:01 -04:00
KoalaTrapLord 8df5900ff9 feat(rag): Phase 1 — Scryfall bulk offline card database (SQLite FTS5) (#117)
* feat(rag): add scryfall_bulk.py — offline SQLite FTS5 card database (RAG Phase 1)

* feat(rag): wire ensure_bulk_db() into startup and add /api/rag/status endpoint (RAG Phase 1)

* fix: migrate on_event to lifespan handler, fix /api/rag/status route ordering
2026-03-27 07:16:55 -04:00
KoalaTrapLord 864cefb8d1 feat(coach): Phase 5 - add PodPresence, TempoAssessment, MetaMatchup models + isQuickDigest flag (#108) 2026-03-27 04:22:23 -04:00
KoalaTrapLord 6c03e56794 fix(#116): dual card_records fallback JOIN, card_type/card_category fields, simulator land detection
- deck_service._compute_deck_analysis: add LEFT JOIN card_records with COALESCE on all fields
- deck_service: add _classify_card_category() with oracle-text keyword matching
- deckbuilder.GET /api/decks/{id}/cards: add card_records fallback JOIN + COALESCE, emit card_type/card_category per card
- deepseek._fetch_deck_card_data: add card_records fallback JOIN so lands get correct type_line before enrich_card()

Closes #116
2026-03-27 04:10:17 -04:00
KoalaTrapLord 4b92a5f364 Update models.pyfeat(coach): Phase 3 - add RemovalCoverage, RampQuality, DrawEngineProfile, WinCondition, AntiSynergyFlag models (#108) 2026-03-27 04:01:25 -04:00
KoalaTrapLord da0193eb11 Update coach_service.pyfeat(coach): Phase 2 - parse upgradePriority, commanderDependency, mulliganAnalysis in coach_service (#108)feat(coach): Phase 2 - parse new fields in coach_service (#108) 2026-03-27 03:45:32 -04:00
KoalaTrapLord 71e0db4985 feat(coach): Phase 2 - add upgradePriority, commanderDependency, mulliganAnalysis to prompt schema (#108) 2026-03-27 03:32:03 -04:00
KoalaTrapLord 825946ae75 fix(deckgen): Phase 1 - non-destructive smart substitution + 99-card guarantee (#111)
- Add SubstitutionMethod enum to track how each card was resolved
- smart_substitute() now always returns a valid card string (never None/drops)
  fallback chain: exact -> fuzzy -> functional analog -> original kept -> basic land
- apply_substitutions() loop no longer silently drops cards on failure
- _pad_to_99() circuit breaker added before BuildResult validation
- Pre-validation count logging per category for fast regression diagnosis
- Startup assertion: sum(SLOT_BUDGET) == 99 raises on app boot if misconfigured
- Prompt injection: slot budget + exact card count requirement sent to LLM
- run_substitution() anthropic fallback failure now pads with basic land instead of leaving slot missing
- SubstitutionBatchResult gains padded_slots field (> 0 = circuit breaker fired)
2026-03-27 03:27:52 -04:00
KoalaTrapLord bd4df0c263 Update models.pyfeat(coach): Phase 2 - add UpgradePriority, CommanderDependency, MulliganAnalysis models (#108) 2026-03-27 02:52:01 -04:00
KoalaTrapLord 113b5c63f0 docs: rewrite README to reflect full current feature set (#115) 2026-03-27 02:51:12 -04:00
KoalaTrapLord 3f6d71ecbc fix(coach): switch all Anthropic calls to streaming to prevent 10-min timeout error (closes #109)
- anthropic_client.py: all 4 methods (chat_structured, chat_plain, achat_structured, achat_plain) now use .messages.stream() context manager
- coach_service.py: inline aclient.messages.create() replaced with async streaming via aclient.messages.stream()
- _parse_response() updated to accept pre-extracted text string (since stream gives us text directly)
- check_status() left as non-streaming (max_tokens=10, safe from the 10min rule)
2026-03-27 02:36:22 -04:00
KoalaTrapLord 4246555228 feat: auto-run update_weights.py after overnight sims (#114)
Completes the sim → weights → deck builder feedback loop.

- Add run_update_weights() to overnight-run.py
- Add auto_update_weights config key (default True)
- Add --no-update-weights, --weights-min-games, --weights-lr CLI flags
- Runs before neural net training so both pipelines get fresh data
2026-03-27 02:35:08 -04:00
KoalaTrapLord 8dd9623417 feat: inject simulation-learned weights into deck builder LLM prompts (#113)
Closes #112

- Add sim_insights.py to translate learned_weights.json into LLM prompt text
- Add sim_context param to suggest_cards() and enforce_deck_ratios() in ollama_client.py
- Wire get_sim_insights() into build_deck pipeline (Steps 4 & 6)
- Fully backwards compatible — no-op until real sim data exists
2026-03-27 02:27:42 -04:00
KoalaTrapLord 817b71f69d feat(ui): add tip-icon tooltips to distillation, simulator, coach, deckgenerator pages
- distillation.html: tooltips on all 10 params + ELO Ratings header
- simulator.html: tooltips on Opponent, Games, Record logs, API Endpoint, Model, Temp, Timeout
- coach.html: tooltips on Power Level, Meta Focus, Budget, Focus Areas, Similar Cards Results
- deckgenerator.html: tooltips on Target Bracket, Use my collection, Smart Substitution, AI Model
- All pages now share tooltip styles from global.css (no per-page duplication needed)
2026-03-27 02:06:58 -04:00
KoalaTrapLord e71cac9dd1 feat(ui): add tooltips to all pages — distillation, simulator, coach, deckgenerator
- Move .tip-icon tooltip CSS from training.css into global.css so all pages share it
- distillation.html: 8 param tooltips (Max Generations, Convergence Window, Supervised Epochs, Supervised LR, PPO Iterations, PPO Episodes/Iter, PPO LR, Min Win Rate Gate, Opponent, Playstyle) + ELO Ratings header
- simulator.html: tooltips on Opponent, Games, Record turn logs, API Endpoint, Model, Temp, Timeout
- coach.html: tooltips on Target Power Level, Meta Focus, Budget, Focus Areas, Find Similar Cards Results
- deckgenerator.html: tooltips on Target Bracket, Use my collection, Smart Substitution, AI Model
2026-03-27 01:57:37 -04:00
KoalaTrapLord cb38081a8b fix: add top_n cutoff to ELO tournament (default top 10 generations) 2026-03-27 01:48:10 -04:00
KoalaTrapLord cd9d2eebd8 feat(coach): Phase 1 - pass richer card stats to Claude prompt (#108)
- _format_underperformers: add synergyScore, clunkinessScore, avgTurnCast, keptInOpeningHandRate
- _format_top_cards: add synergyScore and avgTurnCast
- _format_matchups: append perArchetypeWinRates block from DeckMeta
- USER_PROMPT_TEMPLATE: add ### Archetype Win Rates section placeholder
2026-03-27 01:32:51 -04:00
KoalaTrapLord e6f18d4a61 feat(training): add intro banner and parameter tooltips to ML Training page 2026-03-27 00:18:21 -04:00
KoalaTrapLord 4e90337f54 feat: extend card hover preview to all card names in coach UI 2026-03-26 23:48:27 -04:00
KoalaTrapLord a01a70f725 Use DEFAULT_MAX_TOKENS config for Anthropic max_tokens 2026-03-26 22:50:10 -04:00
KoalaTrapLord 4da58681a4 Enhance coach prompt for more detailed reports 2026-03-26 22:41:23 -04:00
KoalaTrapLord 224b4b5d5b feat: increase coach detail limits (MAX_UNDERPERFORMERS=8, MAX_TOKENS=16384) 2026-03-26 22:33:18 -04:00
KoalaTrapLord 1a34f00574 Update .env.examplefix: correct model ID in .env.example (claude-opus-4-0 -> claude-opus-4) 2026-03-26 15:50:53 -04:00
KoalaTrapLord 8ec7db1612 fix: correct malformed string literals in config.py model defaults 2026-03-26 15:42:34 -04:00
KoalaTrapLord e0a75e2d04 fix: correct Anthropic model ID from claude-opus-4-0 to claude-opus-4 2026-03-26 15:36:23 -04:00
KoalaTrapLord 15bd6f42bb fix: resolve all ruff linting errors across coach module 2026-03-26 15:30:27 -04:00
KoalaTrapLord 0aa57ab7d2 fix: remove duplicate resp token on line 284 in run_coaching_session (#105) 2026-03-26 14:23:52 -04:00
KoalaTrapLord 9f5eb4a99f fix: add missing resp= assignment on line 284 (#105) 2026-03-26 14:11:35 -04:00
KoalaTrapLord a9f18f53da fix: correct indentation of try/except/else block in run_coaching_session (#105) 2026-03-26 14:06:27 -04:00
KoalaTrapLord c9e932ef43 fix: correct try: block indentation in run_coaching_session (#105) 2026-03-26 13:44:33 -04:00
KoalaTrapLord cb99388e3c fix: correct indentation of total_steps in collect_rollouts 2026-03-26 13:14:34 -04:00
KoalaTrapLord 2e316548e7 fix: repair resp= assignment and _re.sub typo in coach_service.py (#105) 2026-03-26 13:11:55 -04:00
KoalaTrapLord b46e1e0877 fix: remove stray string literal in init_coach_service (#105) 2026-03-26 12:31:52 -04:00
KoalaTrapLord e17b877dcc fix: repair syntax errors in coach_status and init_coach_service (#105) 2026-03-26 12:18:07 -04:00
KoalaTrapLord 6ce21908d3 Update coach.py 2026-03-26 11:53:39 -04:00
KoalaTrapLord 15495483f3 fix: remove _coach_init_error reference from status endpoint (#105) 2026-03-26 11:36:33 -04:00
KoalaTrapLord 540fbbf1b3 fix: remove broken _coach_init_error lines from coach.py (#105) 2026-03-26 10:52:41 -04:00
KoalaTrapLord a5cc7736b4 feat: update thread dropdown to 1,2,4,8,16,24,32 2026-03-25 23:15:38 -04:00
KoalaTrapLord 8c6248cf84 feat: increase max threads from 16 to 32 2026-03-25 21:37:21 -04:00
KoalaTrapLord 22d63fd15e Update coach.py 2026-03-25 21:29:24 -04:00
KoalaTrapLord 2dc0ba2d25 Update coach.py 2026-03-25 21:18:27 -04:00
KoalaTrapLord 8f180820f9 fix: add missing _DEFAULT_ORIGINS definition in lab_api.py (#105) 2026-03-25 21:00:34 -04:00
KoalaTrapLord b283418879 fix: correct indentation in _call_anthropic_api else-block (#105) 2026-03-25 20:43:11 -04:00
KoalaTrapLord 61be42ea6d fix: resolve IndentationError on line 882 in routes/deckgen.py (#105) 2026-03-25 20:18:19 -04:00
KoalaTrapLord 3906b93c6a feat: add WebSocket support for Unity game state streaming (#107)
* feat: add ws/ package for WebSocket game state streaming

* feat: add WebSocket ConnectionManager with per-game broadcasting

* feat: add GameStateTracker with deep-diff delta engine

* feat: add WebSocket route with delta push and REST bridge endpoints

* feat: register ws_game_router in lab_api.py
2026-03-25 19:51:15 -04:00
KoalaTrapLord af2a71d1a5 fix: eliminate event-loop-blocking sync calls in async FastAPI routes (#106)
* feat: add async DB wrapper and eliminate event-loop blocking

* fix: rewrite llm_client.py with native async httpx - removes urlopen and run_in_executor

* fix: rewrite routes/coach.py - replace all sync DB/HTTP with async (aiosqlite + httpx)

* fix: add async DB shutdown hook to lab_api.py

* feat: add aiosqlite>=0.19.0 to requirements
2026-03-25 19:17:41 -04:00
KoalaTrapLord cf7c872e12 feat: update test mock from pplx_client to anthropic_client (#105) 2026-03-25 16:43:11 -04:00
KoalaTrapLord c9227dac70 Update substitution_prompt.pyfeat: update substitution_prompt.py docstring from Perplexity to Claude Opus (#105) 2026-03-25 16:41:47 -04:00
KoalaTrapLord 34b1ea2e30 feat: update deck_gen_user.py docstring from Perplexity to Claude Opus (#105) 2026-03-25 16:41:24 -04:00
KoalaTrapLord 1c6454dce5 feat: update substitution_schema.py source field from perplexity to anthropic (#105) 2026-03-25 16:39:16 -04:00
KoalaTrapLord 464dd732d5 feat: update deck_schema.py docstring from Perplexity to Claude Opus (#105) 2026-03-25 16:36:22 -04:00
KoalaTrapLord f481243c4e feat: replace Perplexity AI with Claude Opus in deckgenerator.html (#105) 2026-03-25 16:33:00 -04:00
KoalaTrapLord f49b6cbea5 feat: replace Perplexity AI spinner text with Claude Opus in deckbuilder.html (#105) 2026-03-25 16:30:00 -04:00
KoalaTrapLord 71f316a220 feat: update status label from Perplexity to Claude Opus in coach.html (#105) 2026-03-25 16:26:31 -04:00
KoalaTrapLord 9556f9d7d0 feat: replace Perplexity with Anthropic Claude in lab_api.py (#105) 2026-03-25 15:18:14 -04:00
KoalaTrapLord 9de0965c78 feat: replace Perplexity with Anthropic Claude in routes/deckgen.py (#105) 2026-03-25 15:09:46 -04:00
KoalaTrapLord 38bfa51d85 feat: replace Perplexity with Anthropic Claude in routes/coach.py (#105) 2026-03-25 14:41:34 -04:00
KoalaTrapLord c58f561df4 feat: replace PerplexityClient with AnthropicClient in deck_generator.py (#105) 2026-03-25 14:34:18 -04:00
KoalaTrapLord 6cf1e58018 feat: replace Perplexity with Anthropic Claude in coach_service.py (#105) 2026-03-25 14:30:40 -04:00
KoalaTrapLord 1382d25e46 feat: update clients __init__.py comment to reference Anthropic (#105) 2026-03-25 12:25:56 -04:00
KoalaTrapLord d8f7105471 feat: replace PPLX_API_KEY with ANTHROPIC_API_KEY in install script (#105) 2026-03-25 12:25:01 -04:00
KoalaTrapLord 071088715f feat: replace PPLX_API_KEY with ANTHROPIC_API_KEY in start-lab.bat (#105) 2026-03-25 12:23:43 -04:00
KoalaTrapLord 95429d9f35 feat: replace PPLX_API_KEY with ANTHROPIC_API_KEY in .env.example (#105) 2026-03-25 12:21:40 -04:00
KoalaTrapLord 55379a98ef feat: replace Perplexity config with Anthropic Claude settings (#105) 2026-03-25 12:20:33 -04:00
KoalaTrapLord 173dee6ccc feat: add anthropic>=0.40.0 and openai>=1.0.0 to requirements (#105) 2026-03-25 12:17:52 -04:00
KoalaTrapLord 34dee24399 feat: add AnthropicClient as drop-in replacement for PerplexityClient (closes #105 phase 1) 2026-03-25 12:16:14 -04:00
KoalaTrapLord 88f1022d0e fix: deduplicate card names globally before Scryfall batch lookup
enforce_deck_ratios can return the same card name under multiple
categories (e.g. Teferi's Protection in both protection + synergy).
all_card_names had no cross-category dedup, so get_cards_by_names
returned duplicate CardEntry objects that then failed the singleton
validator in CommanderDeck.

Fix: build all_card_names with dict.fromkeys() across all categories
so each name appears exactly once. Also deduplicate after Scryfall
lookup by name as a second safety net.
2026-03-25 11:23:06 -04:00
KoalaTrapLord 737863c55d fix: .env.example default COACH_PROVIDER to perplexity 2026-03-25 10:43:42 -04:00
KoalaTrapLord e710287c35 fix: run_coaching_session respects COACH_PROVIDER for Perplexity API
The coaching pipeline in coach_service.py was always calling self.llm (Ollama) regardless of COACH_PROVIDER setting. Now branches on COACH_PROVIDER == 'perplexity' to call the Perplexity API directly via httpx, matching the pattern used in routes/coach.py coach_chat endpoint.
2026-03-25 10:42:49 -04:00
KoalaTrapLord 2e3c1ec0f1 fix: startup logs and DeckBuilderAdapter respect COACH_PROVIDER/DECK_GEN_PROVIDER instead of hardcoding Ollama 2026-03-25 10:34:04 -04:00
KoalaTrapLord 21f032d32e fix: set DECK_GEN_PROVIDER default to perplexity and DECK_GEN_MODEL to sonar-pro 2026-03-25 10:29:54 -04:00
KoalaTrapLord 2881309d41 fix: set COACH_PROVIDER default to perplexity, remove stale DeepSeek comment 2026-03-25 10:13:58 -04:00
KoalaTrapLord e162d50608 feat: bulk quantity edit for collection table 2026-03-25 10:12:03 -04:00
KoalaTrapLord 49bab7fb0a fix: set FORGE_DIR to repo root so cardsfolder path resolves correctly 2026-03-25 10:09:10 -04:00
KoalaTrapLord 34e88a3a14 fix: correct indentation of commander_keywords and oracle overlap scoring in deckgen.py 2026-03-25 08:45:51 -04:00
KoalaTrapLord f199bcfa34 feat: column visibility toggle UI for collection table 2026-03-25 08:31:12 -04:00
KoalaTrapLord 77c92d569d fix: correct indentation of elif/else branches in _run_sim_thread_deepseek 2026-03-25 08:24:21 -04:00
KoalaTrapLord f24bd05be8 feat: collection stats dashboard (CMC curve, color/type/rarity/value) 2026-03-25 07:51:09 -04:00
KoalaTrapLord e7ffccf3e8 feat: persist coach sessions to SQLite with disk fallback 2026-03-24 23:44:20 -04:00
KoalaTrapLord 64a65ca2f6 feat: auto-fill deck name with Auto - {CommanderName} on select 2026-03-24 23:19:17 -04:00
KoalaTrapLord 3ba44f68d9 feat: oracle-text keyword overlap scoring in _generate_deck 2026-03-24 22:56:30 -04:00
KoalaTrapLord f851df6dbf fix: add getBaseUrl(), PolicyDecision record, and decide(String,int) to PolicyClient
GameSession.java references these APIs which were missing from
PolicyClient, causing 3 compilation errors. Added:
- getBaseUrl() getter
- PolicyDecision record (action, confidence)
- decide(String, int) overload that delegates to decide(JsonObject)
2026-03-24 22:46:45 -04:00
KoalaTrapLord ded79bfde1 feat: add omit_cards chips styles to deck generator CSS 2026-03-24 22:20:10 -04:00
KoalaTrapLord 411e40e46e feat: add omit_cards chips panel to deck generator HTML 2026-03-24 22:17:25 -04:00
KoalaTrapLord a4e49daf49 feat: add omit_cards chips/tags input to deck generator JS 2026-03-24 22:11:36 -04:00
KoalaTrapLord 675c64029b feat: add progress bar + ETA to deck generation UI 2026-03-24 21:20:47 -04:00
KoalaTrapLord 4c47b9d5e2 fix: add V3 result cache to avoid re-generating deck on export 2026-03-24 21:07:59 -04:00
KoalaTrapLord 8903432d67 fix: add missing export filter params (rarity, setCode, power, toughness, keyword, edhrec, qty) 2026-03-24 21:01:35 -04:00
KoalaTrapLord b1e473397f fix: shopping list field name + scan-add refresh function name 2026-03-24 20:53:19 -04:00
KoalaTrapLord 07b2c9d5a9 fix: add missing LLM_TIMEOUT constant in coach.py
LLM_TIMEOUT was referenced but never defined, causing a NameError
in the Ollama streaming SSE code path when COACH_PROVIDER != 'perplexity'.
2026-03-24 20:43:36 -04:00
KoalaTrapLord 58bbb514ad fix: skip ML decision flush when brain not connected (heuristic fallback guard) 2026-03-24 20:27:02 -04:00
KoalaTrapLord d70d74d753 feat: add Scryfall on-the-fly embedding fallback for unknown cards 2026-03-24 20:09:27 -04:00
KoalaTrapLord 475849e477 fix: prevent double-flush of ML decisions in deepseek sim thread
_run_sim_thread_deepseek was manually calling engine.flush_ml_decisions()
and writing the .jsonl, then passing engine=engine to _finish_sim which
called flush_ml_decisions() a second time — returning an empty list and
producing empty training data files.

Fix: pass engine=None to _finish_sim when the deepseek thread has already
flushed manually, so _finish_sim skips the second flush entirely.
2026-03-24 19:19:45 -04:00
KoalaTrapLord b2d1078ca9 fix: disable quiet mode when ML logging is enabled in createWorker
DecisionExtractor requires verbose Forge output to reconstruct board
states. When quiet=true, Forge runs with -q which strips the verbose
log lines, causing 0 decisions to be extracted. This change passes
quiet=false to BatchRunner when mlLogEnabled is true.
2026-03-24 18:45:36 -04:00
KoalaTrapLord 9bce919690 fix: replace non-existent ForgeEpisodeGenerator import with actual exports 2026-03-24 13:45:48 -04:00
KoalaTrapLord 41b711c17e fix: normalize deck report slug matching (spaces/hyphens/underscores) 2026-03-24 13:31:52 -04:00
KoalaTrapLord a6940ca61a fix: update get_status() to check Perplexity connection when COACH_PROVIDER=perplexity 2026-03-24 13:29:16 -04:00
KoalaTrapLord a65daaf801 fix: update Deck Coach status label from LM Studio to Perplexity 2026-03-24 13:26:41 -04:00
KoalaTrapLord c475f5998b docs: add COACH_PROVIDER to .env.example 2026-03-24 12:54:30 -04:00
KoalaTrapLord 456ef927f9 feat: add Perplexity deep research support to coach chat endpoint 2026-03-24 12:53:42 -04:00
KoalaTrapLord a515049542 feat: add COACH_PROVIDER config for Perplexity-powered coach chat 2026-03-24 12:50:21 -04:00
KoalaTrapLord 457e6f91c8 docs: add PPLX_MODEL to .env.example 2026-03-24 12:47:58 -04:00
KoalaTrapLord 977dbd96d7 feat: use PPLX_MODEL config instead of hardcoded 'sonar' in deckgen 2026-03-24 12:47:09 -04:00
KoalaTrapLord 5a499442e6 feat: add PPLX_MODEL env var for configurable Perplexity model 2026-03-24 12:45:40 -04:00
KoalaTrapLord 1f3b17d173 fix: remove ForgeEpisodeQueue import from training __init__
ForgeEpisodeQueue does not exist in rollout_buffer.py yet.
Removing premature import to unblock supervised training pipeline.
2026-03-24 12:26:06 -04:00
KoalaTrapLord f51ff61568 feat(83): Phase 3 — Performance & Scale (#83) (#101)
* feat(83): Phase 3.1 — persistent JVM worker pool in MultiThreadBatchRunner (#83)

* feat(83): Phase 3.2 — async Forge episode generator with producer-consumer pipeline (#83)

* feat(83): Phase 3.3 — deprecate self_play.py synthetic mode for training (#83)

Mark self_play.py synthetic training as deprecated per Phase 3.3:
- Add deprecation notice to module docstring
- Add import warnings and _TRAINING_DEPRECATION_MSG constant
- Add DeprecationWarning in collect_rollouts()
- Synthetic sim retained for local testing/unit tests only
- scope.py already defaults forge_weight=1.0, ppo_weight=0.0

* feat(83): Phase 3.3 — update ml/training/__init__.py exports (#83)
2026-03-24 10:43:51 -04:00
KoalaTrapLord 39bc36afef rename: update JsonExporter.java javadoc example from batch- to ml-decision- 2026-03-23 23:10:44 -04:00
KoalaTrapLord dffd89a01c rename: update LabCli.java default output from batch-latest to ml-decision-latest 2026-03-23 23:09:51 -04:00
KoalaTrapLord 015b9da1e9 rename: update forge_runner.py file prefix from batch- to ml-decision- 2026-03-23 23:06:33 -04:00
KoalaTrapLord f9007effa3 rename: update report_generator.py glob from batch- to ml-decision- 2026-03-23 23:05:25 -04:00
KoalaTrapLord 29ad996966 rename: batch-sim- to ml-decision-sim- prefix in deepseek.py 2026-03-23 23:04:13 -04:00
KoalaTrapLord 51b4e9b5ff rename: batch result files from batch- to ml-decision- prefix in lab.py 2026-03-23 23:00:24 -04:00
KoalaTrapLord 573a271561 fix: use correct query param format for batch status endpoint in overnight-batch.py
The /api/lab/status endpoint uses ?batchId= query param, not path param /{batch_id}
2026-03-23 22:19:21 -04:00
KoalaTrapLord 411fa41063 feat(83): Phase 2 — Live Forge-to-Policy-Server IPC (#100)
* feat(83): add PolicyClient for live Forge-to-policy-server IPC (#83)

Phase 2.1: Bridge between Forge decision points and Python policy
server for online training. Calls /api/policy/decide endpoint,
collects (state, action, reward) tuples in real-time, and flushes
them to the server for online PPO updates.

Falls back to Forge built-in AI if server is unavailable.

* feat(83): add /api/policy/* routes for live Forge IPC (#83)

Phase 2.2: Policy decision routes for real-time Forge IPC.

- POST /api/policy/decide — full game state inference with log_prob/value
- POST /api/policy/collect — online learning tuple batch collection
- POST /api/policy/reward — end-of-game reward propagation
- GET /api/policy/health — readiness check for Java PolicyClient
- GET /api/policy/stats — session statistics

Includes Forge phase mapping and PlayerZoneState with full zone contents.

* feat(83): integrate PolicyClient into GameSession for bidirectional sync (#83)
2026-03-23 22:08:37 -04:00
87404f3702 refactor: move logging out of routes/shared.py into services/logging.py (#99)
Create services/logging.py as the canonical home for setup_logging(),
all log format constants, named loggers, and _ml_logging_enabled.

Update all route modules and lab_api.py to import logging symbols from
services.logging and all other symbols from their canonical sources
(models.state, models.requests, models.responses, services.database,
services.deck_service, services.scryfall, services.import_helpers,
services.import_service, services.precon_service, services.forge_runner).

Delete routes/shared.py — the re-export hub is no longer needed.

Closes #61

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 21:04:25 -04:00
KoalaTrapLordandClaude Code f0dfb41f2f fix: default Ollama endpoints to localhost instead of 192.168.0.240 (#98)
The hardcoded 192.168.0.240 IP was a developer-specific LAN address
that breaks out-of-the-box for anyone running Ollama locally or via
WSL2 on Windows. Switch every default and docstring reference to
localhost so the project works without manual configuration.

Files changed:
- coach/config.py: LLM_URL and DECK_GEN_BASE_URL defaults
- ui/simulator.html: API endpoint input value and placeholder
- lab_api.py: remove LAN IP from default CORS origins
- routes/coach.py: warning message text
- src/commander_ai_lab/sim/deepseek_brain.py: docstrings
- src/commander_ai_lab/sim/deepseek_engine.py: docstring example
- src/commander_ai_lab/sim/validator_brain.py: docstring example

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-03-23 21:03:17 -04:00
56e5bff496 fix: correct indentation of last_err in DeepSeekBrain.choose_action() (#97)
`last_err = None` was at module-level indentation (4 spaces) instead of
method-body indentation (8 spaces), causing an IndentationError on import.

Co-authored-by: Claude Agent <claude-agent@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 20:01:11 -04:00
KoalaTrapLord 62463dfa4a fix: correct indentation in deepseek_brain.py (choose_action + _parse_action) 2026-03-23 19:34:14 -04:00
KoalaTrapLord 7e0aba78ba fix: add ML logging support to MultiThreadBatchRunner 2026-03-23 19:01:43 -04:00
KoalaTrapLord 7036c9568f fix: enable ML decision logging in multi-thread batch path 2026-03-23 18:58:14 -04:00
KoalaTrapLord b37968e3c2 fix: always pass --ml-log flag to Forge Java batch runner 2026-03-23 17:22:58 -04:00
KoalaTrapLord d424ccb74c feat(86): add combat priority checkpoints with CombatState and split engine methods (#96) 2026-03-23 16:52:19 -04:00
KoalaTrapLord 1cf2439a5a fix(#95): correct validator defaults to deepseek-r1:8b and 300s timeout
_get_validator_brain() defaulted VALIDATOR_MODEL to deepseek-r1:14b
and VALIDATOR_TIMEOUT to 120.0, conflicting with the brain config.
Changed to deepseek-r1:8b and 300.0 to match. Fixes issue #95 item 4.
2026-03-23 14:31:59 -04:00
KoalaTrapLord f3bd188a8b fix(#95): implement retry loop and bump max_tokens/max_retries defaults
max_retries was configured but never used — choose_action() made
exactly one attempt. Now wraps _call_llm() in a retry loop.
Bumped max_retries default to 2 and max_tokens to 4096 (R1 needs
room for <think> blocks + JSON). Fixes issue #95 item 3.
2026-03-23 14:29:22 -04:00
KoalaTrapLord c1ad9e953c fix(#95): replace regex JSON extraction with outermost-brace approach
_parse_action() used r'\{[^{}]*\}' regex which fails on nested
JSON (e.g. oracle text with braces). Now uses text.find/rfind to
extract the outermost { } block, handling nested structures.
Fixes issue #95 item 2.
2026-03-23 14:24:57 -04:00
KoalaTrapLord 25e064d265 fix(#95): prevent check_connection from overwriting configured model
check_connection() was auto-overwriting self.config.model with the
first model returned by /v1/models, causing deepseek-r1:8b to be
replaced by deepseek-r1:14b (or whatever Ollama listed first).

Now logs available models without changing the configured model.
Fixes issue #95 item 1.
2026-03-23 10:37:39 -04:00
2b6d4dcdb1 fix: deduplicate LLM duplicate cards in _check_ownership (#78) (#79)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:42:33 -04:00
eee773b6f9 Forge Card Enrichment — Full Implementation (Issue #80) (#81)
* feat(issue-80): Phase 1 — add services/forge_card_loader.py

* feat(issue-80): Phase 2 — add 5 Forge fields to Card dataclass

* feat(issue-80): Phase 3+4 — wire Forge into enrich_card/score_card

* feat(issue-80): Phase 5 — add tests/sim/test_forge_enrichment.py

* fix(issue-80): resolve PR #81 blocking issues — import paths, indentation, partial Forge fallback

- Move forge_card_loader.py from services/ into src/commander_ai_lab/sim/
  so the import in rules.py uses a proper intra-package path instead of
  crossing package boundaries via services.forge_card_loader
- Fix indentation of Forge enrichment comment in models.py (8→4 spaces)
- Fix enrich_card() early return on partial Forge data: re-check
  has_real_data after _apply_forge_data so cards with incomplete Forge
  entries still fall through to known-cards / default heuristics
- Fix flawed test assertion in test_etb_via_forge_mode_equals_text_detection:
  oracle text "draw a card" was adding a +3 spell_draw bonus, making the
  score comparison invalid; replaced with side-effect-free ETB text

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

---------

Co-authored-by: Claude Code <claude-code@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 09:37:05 -04:00
2eae7dcbd5 fix(ci): remove frontend job that references non-existent directory (#94)
The CI workflow had a `frontend` job that tried to run npm ci/build/test
in a `frontend/` directory which does not exist in this repo. The actual
UI lives in `ui/` as static HTML/CSS/JS (no Node.js toolchain). This
caused every CI run to fail on an otherwise valid codebase.

Co-authored-by: Claude Agent <claude-agent@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:54:16 -04:00
0f8d1382e0 feat: explicit turn-phase structure with Phase enum and main2 support (#86) (#93)
Refactor the headless engine to walk through all 12 MTG turn phases in
order (untap → upkeep → draw → main1 → begin_combat → declare_attackers
→ declare_blockers → combat_damage → end_combat → main2 → end_step →
cleanup) instead of the previous flat per-player block.

Key changes:
- Add Phase str enum and PHASE_ORDER/SORCERY_PHASES constants to models
- Track current_phase and active_player_index on SimState
- Sync sim.turn with the loop turn counter (fixes latent summoning-
  sickness bug where turn_played was always compared against 0)
- Add second main phase (main2) with shared 2-spell budget across
  main1 + main2
- _play_spells now accepts max_spells param and returns count played
- Unify turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES to derive
  from the canonical Phase enum in models.py
- 33 regression tests covering phase enum, phase progression, spell
  budget, game log structure, backward compat, and turn manager
  constant consistency

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:46:09 -04:00
KoalaTrapLord b72d5fba83 fix(#92): bump READ_TIMEOUT_MS from 5s to 300s in PolicyServerClient.java 2026-03-22 23:44:47 -04:00
KoalaTrapLord 5d34d07bb8 fix(#92): bump validator_brain.py timeouts from 60s to 300s 2026-03-22 23:43:30 -04:00
KoalaTrapLord 5e9685ba70 fix(#92): read BRAIN_TIMEOUT env for ai_decision_timeout + add import os in turn_manager.py 2026-03-22 23:42:28 -04:00
KoalaTrapLord 49fc817187 fix(#92): bump BRAIN_TIMEOUT fallback from 120s to 300s in forge_runner.py 2026-03-22 23:40:28 -04:00
KoalaTrapLord c2b3d937a4 fix(#92): read BRAIN_TIMEOUT env for request_timeout in deepseek_brain.py 2026-03-22 23:39:33 -04:00
5e9c568bcc feat: implement London mulligan and opening-hand validation (#86) (#91) (#91)
Add London mulligan rule to the headless game engine:

- GameEngine now accepts mulligan_rule param ("london" default, "none" for legacy)
- AI heuristic: mulligan 0-1 land or 6-7 land hands; relax after 2+ mulligans
- After keeping, bottom N lowest-scored cards (London mulligan rule)
- Cap at MAX_MULLIGANS=4 to prevent infinite loops on degenerate decks
- Track mulligans and opening_hand_lands in PlayerStats
- Serialize both fields in to_dict() for batch-result schema compatibility
- 28 targeted regression tests covering unit helpers, integration,
  N-player, commander interaction, serialization, and legacy mode

Co-authored-by: Claude Agent <claude-agent@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 23:28:01 -04:00
KoalaTrapLordandClaude Code 85aa15445b feat: route direct-damage spells through centralized deal_damage() (#86) (#90)
Add centralized deal_damage() method to GameEngine that handles life
reduction, stat tracking, commander damage (both per-seat and per-card),
and elimination checks. Route both burn spells and combat damage through
this unified path, building on the dual-dict commander damage model
merged in PR #89.

- Add is_direct_damage / direct_damage_amount fields to Card
- Add burn spell detection via known-card table + oracle regex fallback
- Route burn spells in _play_spells() through deal_damage()
- Refactor _resolve_combat() to route per-attacker damage through deal_damage()
- Add 38 regression tests covering deal_damage(), burn detection, spell routing,
  combat routing, and full-game integration

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-03-22 22:27:37 -04:00
KoalaTrapLordandClaude e265b1e15b feat: commander damage source identity and per-card tracking (#86) (#89)
Track commander damage at combat-damage time by checking `is_commander`
on each attacking creature.  Damage from commander creatures is now
recorded in both a per-seat aggregate (`commander_damage_received`) and
a per-card breakdown (`commander_damage_by_card`), enabling correct
21-damage lethal checks for partner/multi-commander games.

Changes:
- engine._resolve_combat: identify commander attackers, accumulate their
  damage (including trample overflow and double strike), write to both
  tracking dicts, and check commander-damage elimination after combat.
- models.Player: add commander_damage_received, commander_damage_by_card,
  and is_dead_to_commander_damage() so the headless engine can track
  commander damage without the full CommanderGameState wrapper.
- game_state.CommanderPlayer: replace the separate
  commander_damage_received field with a property delegating to
  base.Player to avoid split-brain state between engine and wrapper.
- game_state.CommanderGameState.deal_commander_damage: accept optional
  commander_name for per-card tracking.
- 22 new regression tests covering unblocked, blocked, trample, double
  strike, lifelink, partner, cumulative, and lethal scenarios.

Partial fix for Issue #86 item 2.

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-22 22:04:19 -04:00
KoalaTrapLord ae8d14c19e chore: point deepseek brain at deepseek-r1:8b on localhost 2026-03-22 21:49:45 -04:00
KoalaTrapLord f090a36f79 chore: switch deck builder default model to qwen2.5:7b 2026-03-22 21:40:26 -04:00
cf4e2981e6 fix: resolve 78 test failures via shared stub cleanup and dependent fixes (#85) (#88)
Centralize and enhance test stubs to implement the full CommanderGameState
interface (battlefield(), living_players(), stack_is_empty(), sim_state,
active_player(), priority_player(), to_dict()). Fix all dependent test
failures across 12 files:

- conftest.py: Enhanced StubCard/StubPlayer/StubGameState with full API
  compatibility; added basic_creature fixture; fixed fresh_app singleton leak
- test_phase3.py: Added battlefield(), active_player(), prompt_builder compat
- test_integration.py: Fixed ThreatAssessor import (class→function), added
  threat_fn dict wrapper, sim_state delegate
- test_phase6.py/test_phase8.py: Replaced deprecated get_event_loop() with
  asyncio.run()
- test_benchmark.py: Same async fix + ThreatAssessor functional API
- sim/test_engine.py: Removed nonexistent seed= param, use run() method
- sim/test_models.py: basic_creature fixture now in conftest
- deck_builder/test_models.py: Updated ValidationError regex match
- state_logger.py: Added monotonic counter for unique snapshot filenames
- ai_opponent.py: Moved _CANNED dict to module-level constant (dataclass fix)
- web/app.py: Mount lobby router (fixes 6 LobbyAPI test failures)
- test_phase5.py: SessionStore + api._store reset fixture

Result: 76 failures + 2 errors → 0 failures, 0 errors (331 passed, 30 skipped)

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:32:30 -04:00
d795e1a8fd feat: Phase 5 architecture follow-up — zone boundary, WAL store, Forge-only defaults (#87)
Implements the three approved Phase 5 architecture decisions:

1. Zone embedding boundary: add explicit documentation in StateDimensions
   and StateEncoder._encode_zones making the 6144-dim zero-padded zone
   contract visible to future maintainers.

2. SQLite WAL online learning store: new OnlineLearningStore class backed
   by WAL-mode SQLite for concurrent read/write on single-machine baseline.
   New /api/policy/decide endpoint collects decisions during inference.
   Stats endpoint at /api/policy/decide/stats.

3. Forge-only default weights: change ppo_weight default from 0.5 to 0.0
   across scope.py, DistillationConfig, DatasetConfig, and CLI defaults.
   Add MIXED_MODE_PRESETS dict with forge_only, forge_90_10, forge_80_20
   presets. Distill/start endpoint now accepts a "preset" parameter.

27 new tests covering all three decisions (all passing).

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 14:41:33 -04:00
KoalaTrapLord 01d49e78a5 Fix coach cards-like: replace missing image_url column with scryfall_id URL builder 2026-03-22 13:06:36 -04:00
KoalaTrapLord c528fdb0b1 feat: add Scryfall card image hover preview on coach suggested cuts/adds 2026-03-22 12:30:00 -04:00
KoalaTrapLord e4bc3c5ce6 fix: use project root not routes/ for results_dir and reports_dir in coach 2026-03-22 12:07:56 -04:00
KoalaTrapLord d3c9e293d8 fix: mutate COMMANDER_META in-place so cross-module refs stay synced 2026-03-22 12:01:53 -04:00
ClaudeandClaude Opus 4.6 978fb12969 fix: early return in _load_deck_cards_by_name when deck not found in DB
Fixes UnboundLocalError crash when deck_row is None by returning an
empty list instead of falling through to a query with an unbound deck_id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 07:25:39 +00:00
KoalaTrapLord 8c2a8cf1c1 fix: align indentation on line 108 to match surrounding block 2026-03-22 02:38:28 -04:00
KoalaTrapLord fd18d98b9b fix: correct indentation on line 108 in routes/lab.py 2026-03-22 02:35:30 -04:00
KoalaTrapLord 5c21564c78 fix: correct indentation on line 786 in deckbuilder.py 2026-03-22 02:02:22 -04:00
KoalaTrapLord e35df6fe36 fix: auto-export deckbuilder decks to .dck before batch simulation 2026-03-22 01:41:55 -04:00
KoalaTrapLord 7760c589b9 fix: add Deck Type=Commander metadata to .dck export 2026-03-22 01:39:17 -04:00
KoalaTrapLord 7fa262d963 fix(ui): account for nav-height in collection max-height calcs 2026-03-22 00:52:54 -04:00
KoalaTrapLord cffd8d8109 fix: add creature damage accumulation to combat engine
Previously _resolve_combat() used a binary power-vs-toughness kill check:
creatures only died if a single source had power >= their toughness in one
swing. This meant a 2/2 wouldn't die to two 1/1 tokens blocking it, and
repeated smaller hits across combats never accumulated.

Changes:
- models.py: add damage_marked field to Card (default 0)
- engine.py: accumulate damage via damage_marked += power instead of
  binary a_pow >= toughness checks in _resolve_combat()
- engine.py: clear damage_marked = 0 in untap phase (MTG rule 514.2)

This enables multi-blocker lethality, incremental chip damage within a
combat step, and proper creature death-by-accumulated-damage interactions.
2026-03-22 04:52:28 +00:00
KoalaTrapLord 473c453abf fix(ui): add #coll-card-preview CSS for hover image preview 2026-03-22 00:34:54 -04:00
KoalaTrapLord 54080eec37 Merge pull request #77 from KoalaTrapLord/feat/overnight-batch-runner
feat: overnight batch sim runner for ML data generation
2026-03-21 22:31:14 -04:00
rgriffith 5cb4bc3316 feat: add overnight batch sim runner for ML data generation
Script to run sequential batch sims across 10 precon decks with
rotating playstyles (midrange, aggro, control, combo). Generates
ML decision data for training.

- 210 unique 4-player matchups from 10 precon decks
- Cycles through playstyles each batch
- Polls for completion, handles errors gracefully
- Ensures ML logging is enabled before starting
- Prints summary with new decision count at end

Usage: python scripts/overnight-batch.py --hours 8
2026-03-22 02:31:05 +00:00
KoalaTrapLord 099e40d7c5 fix(ui): rewrite CollCardPreview multi-line, use imageUrl instead of scryfallId 2026-03-21 22:19:51 -04:00
KoalaTrapLord 57c4d6a26c Merge pull request #76 from KoalaTrapLord/fix/distillation-results-dir
fix: distillation loop uses wrong results_dir path
2026-03-21 22:18:34 -04:00
rgriffith ea3d488a41 fix: distillation loop uses wrong results_dir path
The distillation pipeline was looking for JSONL decision files in
data/results/ but they are stored in results/ (matching CFG.results_dir).

This caused all generations to fail at Step 1 with:
  'No ml-decisions-*.jsonl files found in data/results'

Fixed:
- routes/ml.py: results_dir and history_dir now use 'results/' not 'data/results/'
- ml/training/distillation_loop.py: updated DistillationConfig defaults to match
- History endpoint paths updated to match
2026-03-22 02:18:23 +00:00
KoalaTrapLord 83ee5ba9cc feat(ui): enable card image hover preview on collection table 2026-03-21 22:10:28 -04:00
KoalaTrapLord 5b532787af feat(ui): add card hover preview element to collection page 2026-03-21 22:05:00 -04:00
KoalaTrapLord 1b88cd362f tweak: reduce card preview hover delay to 225ms 2026-03-21 22:01:28 -04:00
KoalaTrapLord 834f8d9aad fix: Meta Commander picker — lazy-load, live search, debounce
- showMetaPicker() now guards for no backend with clear message,
  and lazy-loads commanders on first open (fixes race condition
  with Promise.all where loadMetaCommanders ran before detectBackend)
- filterMetaCommanders() hits live /api/lab/meta/search endpoint
  when backend is connected, falls back to local array filter
- Search input debounced at 250ms to avoid hammering API
2026-03-22 01:57:12 +00:00
KoalaTrapLord a7bc98c227 feat(ui): add 450ms hover delay to card preview popup
- Add _hoverTimer and HOVER_DELAY (450ms) properties to CardPreview
- mouseenter starts a delay timer before showing the popup
- mousemove only repositions if popup is already visible
- mouseleave clears the timer and hides immediately
- hide() now also clears any pending timer
2026-03-21 21:52:11 -04:00
KoalaTrapLord 3e08d2bed1 feat(ui): add card image hover preview element to deckbuilder
Add #deck-card-preview div with img element that the existing
CardPreview JS object references. This was missing from the HTML.
2026-03-21 21:46:47 -04:00
KoalaTrapLord e4730ad8c7 Merge pull request #74 from KoalaTrapLord/phase6-elo-tracking
Phase 6: ELO Tracking & Model Tournament
2026-03-21 20:31:22 -04:00
rgriffith e843528d8b Phase 6: ELO Tracking & Model Tournament (#70)
- Create ml/eval/elo_tracker.py:
  - EloTracker class with standard ELO formula (K=32, start=1200)
  - EloResult dataclass for tournament output
  - EloHistory class for JSON persistence (data/elo_history.json)
  - run_elo_tournament(): round-robin ELO-rated matches using
    existing run_match() from tournament.py
  - run_generation_tournament(): discovers gen-*/best_policy.pt
    checkpoints and runs cross-generation ELO tournament
  - CLI entry point: python -m ml.eval.elo_tracker

- Add ELO API endpoints to routes/ml.py:
  - POST /api/ml/elo/tournament — start background ELO tournament
  - GET  /api/ml/elo/tournament/status — poll progress
  - GET  /api/ml/elo/history — load ELO history from disk

- Integrate ELO convergence in distillation_loop.py:
  - New config fields: elo_convergence_enabled (default False),
    elo_convergence_threshold (20.0), elo_tournament_episodes (30)
  - New field: GenerationRecord.elo_rating
  - _run_elo_mini_tournament() runs after quality gate pass
  - _check_convergence() checks ELO delta when enabled

- Add ELO UI to distillation page:
  - ELO Ratings panel with canvas multi-line chart showing per-model
    ELO progression across generations
  - Run Tournament button with progress indicator
  - ELO leaderboard table with rank, rating, and visual bars
  - Responsive chart resize handling

Closes #70
2026-03-22 00:31:02 +00:00
KoalaTrapLord dd2e945214 Merge pull request #73 from KoalaTrapLord/phase5-distillation-ui
Phase 5: Distillation UI Integration & Monitoring
2026-03-21 20:19:04 -04:00
rgriffith 106ec716a5 Phase 5: Distillation UI Integration & Monitoring (#69)
- Add distillation API endpoints to routes/ml.py:
  - POST /api/ml/distill/start — start closed-loop distillation pipeline
  - GET  /api/ml/distill/status — poll loop status and generation data
  - POST /api/ml/distill/stop — graceful stop after current generation
  - GET  /api/ml/distill/history — load persisted generation history

- Add 'Distill' nav entry in ui/nav.js with loop icon

- Create ui/distillation.html:
  - Status overview cards (status, generation, best win rate, gate passes)
  - Control panel with hyperparameter inputs for supervised, PPO, and
    quality gate settings
  - Start/Stop buttons with progress bar and phase badge
  - Win rate trend chart (canvas-based, no dependencies)
  - Data composition stacked bar chart (Forge vs PPO per generation)
  - Generation history table with status, metrics, and gate results

- Create ui/distillation.js:
  - Polling loop for real-time status updates during distillation
  - Canvas chart rendering for win rate trends and data composition
  - History table rendering with color-coded status tags
  - Responsive resize handling for charts

- Create ui/distillation.css:
  - Page-specific styles following training.css patterns
  - Chart container, history table, and responsive grid styles

Closes #69
2026-03-22 00:18:43 +00:00
KoalaTrapLord 9bec84e997 Merge pull request #72 from KoalaTrapLord/phase4-distillation-loop
feat: Phase 4 — Distillation Loop Orchestrator
2026-03-21 20:11:31 -04:00
rgriffith ae00dd47e2 feat: Phase 4 — Distillation Loop Orchestrator (#68)
Implements the closed-loop distillation pipeline that automates:
  1. Supervised train on current dataset (Forge + PPO data)
  2. PPO self-play using supervised model as starting policy
  3. Export winning PPO decisions to JSONL
  4. Quality gate check (win rate threshold)
  5. If passed, merge into dataset and repeat
  6. If failed, discard batch, adjust hyperparameters, retry

New files:
  - ml/training/distillation_loop.py — DistillationLoop orchestrator,
    DistillationConfig, GenerationRecord, CLI entry point

Updated files:
  - ml/config/scope.py — DistillationDefaults dataclass with all
    default hyperparameters for the pipeline

Features:
  - Generation tagging: data and models tagged with gen-NNN
  - Convergence detection: stops when win rate plateaus over N gens
  - Automatic retry with LR reduction + entropy boost on gate failure
  - Per-generation history saved to JSON for observability
  - Full CLI with all tunable params
  - progress_callback for UI integration (Phase 5 ready)

Closes #68
2026-03-22 00:11:14 +00:00
KoalaTrapLord dc66ed78e5 fix(lab): remove shadowed _ml_logging_enabled redeclaration (closes #64) 2026-03-22 00:10:12 +00:00
KoalaTrapLord 0f6faa5fe4 feat(ml): integrate DecisionExporter into self-play rollouts (#65) 2026-03-22 00:06:55 +00:00
KoalaTrapLord 1164285a5b docs: add Phase 1 self-play integration checklist (#65) 2026-03-21 19:51:27 -04:00
KoalaTrapLord d9ee09b848 docs: update .env.example to match dual-model config (gpt-oss:20b + deepseek-r1:14b) 2026-03-21 19:47:17 -04:00
KoalaTrapLord d5c6746503 docs: add BRAIN_* and VALIDATOR_* vars to .env.example (#71) 2026-03-21 19:41:47 -04:00
KoalaTrapLord 1b95659f14 fix: add LAN origins (192.168.0.240) to default CORS allowlist (#71) 2026-03-21 19:41:05 -04:00
KoalaTrapLord 871934d101 fix: raise VALIDATOR_TIMEOUT default from 60s to 120s (#71) 2026-03-21 19:40:19 -04:00
KoalaTrapLord 11978736a1 fix: raise BRAIN_TIMEOUT default from 30s to 120s (#71) 2026-03-21 19:39:29 -04:00
KoalaTrapLord 8ed3fbc627 feat(ml): add quality gate & validation module (#67)
Phase 3 of closed-loop distillation pipeline.

- QualityGateConfig with configurable thresholds
- create_forge_holdout() for Forge-only eval split (12% by game_id)
- check_accuracy_gate() rejects PPO batch if Forge eval accuracy drops
- check_win_rate_gate() blocks export below configurable win rate
- compute_quality_metrics() logs action entropy, reward distribution
- QualityGate orchestrator class with evaluate_ppo_batch()
2026-03-21 19:34:37 -04:00
KoalaTrapLord 4d50449145 feat(ml): add source awareness to dataset builder (#66)
Phase 2 of closed-loop distillation pipeline.

- Scan both ml-decisions-sim-*.jsonl (Forge) and ml-decisions-ppo-*.jsonl (PPO)
- Add source_weights config for configurable sampling ratios
- Add min_reward_threshold filter for PPO data quality
- Tag each sample with source for downstream analysis
- New CLI args: --forge-weight, --ppo-weight, --min-reward
- NPZ output now includes 'sources' array
2026-03-21 19:28:22 -04:00
KoalaTrapLord f986d236ab Reduce coach prompt limits and raise LLM timeout for R1 compatibility 2026-03-21 19:08:24 -04:00
KoalaTrapLord 3957285a2e feat(ml): add PPO DecisionExporter for closed-loop distillation (#65)
Phase 1 of closed-loop distillation pipeline.

Adds DecisionExporter class that captures PPO self-play decisions
as JSONL files compatible with the Forge DecisionSnapshot schema.
Includes PPO-specific metadata fields (source, model_version,
reward, episode_return) and configurable win-only filtering.
2026-03-21 18:06:45 -04:00
KoalaTrapLord 3cca49c5a9 docs: add closed-loop iterative distillation implementation roadmap 2026-03-21 17:55:18 -04:00
KoalaTrapLord f290523ce1 fix(ml): replace undefined 'log' with 'logger' in labeler.py 2026-03-21 15:47:47 -04:00
KoalaTrapLord 448cec128c feat(ui): add 12 and 16 thread options to batch runner dropdown 2026-03-21 15:22:20 -04:00
KoalaTrapLord 6612f3d213 fix: use correct key best_val_acc in training completion message 2026-03-21 15:21:35 -04:00
KoalaTrapLord 06f007e605 fix: invert guard in _load_deck_cards_by_name — was if not deck_row (crash on None[0])
The guard condition was inverted: `if not deck_row:` entered the query
block when no deck was found, causing `deck_row[0]` to crash with
'NoneType' object is not subscriptable. Flipped to `if deck_row:`
so the query runs only when the deck exists.
2026-03-21 15:11:08 -04:00
KoalaTrapLord 1397f93b91 feat(ml): wire ML decision flush in _finish_sim for all sim threads 2026-03-21 14:30:47 -04:00
KoalaTrapLord f86a4af86f feat(ml): add ML decision logging to base GameEngine
- Add ml_log param, ml_decisions list, flush_ml_decisions() to GameEngine
- Capture ML snapshots at each player turn in run_n()
- Stamp game_outcome (win/loss per seat) on all decisions
- Add _capture_ml_snapshot() matching DeepSeekGameEngine format
- Add game_id and archetype params to run_n() for ML tracking
- Import logging, uuid; add logger
2026-03-21 13:54:33 -04:00
KoalaTrapLord 0e99f814fc chore: remove unused React scaffold frontend/ 2026-03-21 13:45:08 -04:00
246 changed files with 22430 additions and 22247 deletions

No files matched your search

+25 -4
View File
@@ -1,4 +1,4 @@
# Commander AI Lab — Environment Variables
# Commander AI Lab - Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
@@ -6,10 +6,31 @@
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
# Get yours at: https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# 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
# 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
# ── 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
# 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
-20
View File
@@ -33,26 +33,6 @@ jobs:
- name: Run tests
run: pytest -v --tb=short tests/ || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run frontend tests
working-directory: frontend
run: npx vitest run
java-tests:
runs-on: ubuntu-latest
steps:
+132 -333
View File
@@ -1,381 +1,180 @@
# Commander AI Lab
Headless batch simulation environment for 3-AI MTG Commander pods.
Run 100+ games, analyze win rates, and stress-test your decks — all from a web UI or CLI.
A full-stack MTG Commander AI research platform. Simulate hundreds of games overnight, train ML models on the results, and let simulation data automatically shape the AI deck builder — all from a web UI or CLI.
## What It Does
| Layer | What it is |
|---|---|
| **Monte Carlo Engine** | Pure-Python headless sim — runs thousands of games in minutes, no JVM needed |
| **Forge Engine** | Java/Forge rules-complete sim for ground-truth validation |
| **Overnight Runner** | Unattended batch runner — runs sims, updates sim weights, trains neural net |
| **ML Pipeline** | Outcome-weighted RL nudges card scoring weights; neural net trains on decision logs |
| **Deck Builder** | 7-step LLM pipeline (EDHrec → Scryfall → Ollama) now injected with sim-learned insights |
| **AI Coach** | LLM-powered coaching sessions (Anthropic/Ollama) with streaming support |
| **Web UI** | React + FastAPI dashboard for decks, sims, training, and deck building |
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Web Browser (ai-lab.js + ai-lab.css) │
│ Select decks → Start batch → Poll progress → View stats │
└─────────────────────┬───────────────────────────────────────┘
│ HTTP (REST)
┌─────────────────────┴───────────────────────────────────────┐
│ Python FastAPI Server (lab_api.py, port 8080) │
│ /api/lab/start — spawn Java batch runner subprocess │
│ /api/lab/status — poll progress (parses stdout) │
│ /api/lab/result — return completed JSON results │
│ /api/lab/decks — list decks from Forge user data │
│ /api/lab/history— browse past batch runs │
│ /api/lab/log — stream live console output │
└─────────────────────┬───────────────────────────────────────┘
│ subprocess (java -jar)
┌─────────────────────┴───────────────────────────────────────┐
│ Java CLI (LabCli.java + BatchRunner + MultiThreadRunner) │
│ Parses Forge sim output → GameResult objects → JSON │
└─────────────────────┬───────────────────────────────────────┘
│ subprocess per game
┌─────────────────────┴───────────────────────────────────────┐
│ Card Forge Engine (forge-gui-desktop sim mode) │
│ java -jar forge.jar sim -d "deck1" "deck2" "deck3" │
│ -f commander -n 1 -q │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Web UI (React/TypeScript — frontend/) │
│ Deck Builder · Sim Dashboard · Training · Coach │
└────────────────────────┬────────────────────────────────────────┘
│ HTTP/REST + WebSocket
┌────────────────────────┴────────────────────────────────────────┐
│ FastAPI Server (lab_api.py / routes/ — port 8080) │
│ /api/lab/* /api/ml/* /api/coach/* /api/deck-builder/* │
└────────┬──────────────────┬──────────────────┬──────────────────┘
│ │ │
Java subprocess Python sim engine Ollama (local LLM)
(Forge rules) (Monte Carlo) qwen2.5:7b + others
│ │
┌────────┴───────┐ ┌───────┴────────────────────────────────────┐
│ Forge Engine │ │ src/commander_ai_lab/ │
│ (rules- │ │ sim/ — engine, models, rules │
│ complete) │ │ deck_builder/ — 7-step LLM pipeline │
└────────────────┘ │ coach/ — streaming LLM coach │
└────────────────────────────────────────────┘
```
## Quick Start (Windows)
### Prerequisites
- **Java 17+** (`java -version`)
- **Python 3.10+** (`python --version`)
- **Maven 3.8+** (`mvn --version`)
- **Forge repo** cloned and built at `D:\ForgeCommander\forge-repo`
**Prerequisites:** Java 17+, Python 3.10+, Maven 3.8+, [Forge](https://github.com/Card-Forge/forge) built at `D:\ForgeCommander\forge-repo`
### 1. Build Forge (one-time)
```powershell
# 1. Build Forge (one-time)
cd D:\ForgeCommander\forge-repo
mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
```
### 2. Build Commander AI Lab
```powershell
# 2. Build Commander AI Lab
cd D:\ForgeCommander\commander-ai-lab
mvn package "-DskipTests"
```
This produces `target/commander-ai-lab-1.0.0-SNAPSHOT.jar` (shaded fat JAR).
### 3. Install Python dependencies
```powershell
pip install fastapi uvicorn
```
# 3. Install Python deps
pip install -e .
### 4. Launch the Web UI
**Option A — One-click launcher:**
```powershell
# 4. Launch
.\start-lab.bat
# → open http://localhost:8080
```
Edit the paths at the top of `start-lab.bat` if needed.
**Option B — Manual launch:**
## Overnight AFK Runner
Runs batch sims unattended, then updates card weights and trains the neural net:
```powershell
python lab_api.py `
--forge-jar "D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
--forge-dir "D:\ForgeCommander\forge-repo\forge-gui" `
--port 8080
python overnight-run.py # default: 11.5h DeepSeek sims
python overnight-run.py --batches 20 --games 50 # fixed batch count
python overnight-run.py --no-update-weights # skip weight update
python overnight-run.py --no-train # skip neural net training
```
Open `http://localhost:8080` — select 3 decks, set game count, click **Run AI Sims**.
**What happens after sims complete:**
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:
```
Overnight sims → decisions_*.jsonl
↓
update_weights.py → learned_weights.json
↓
sim_insights.py → natural-language insight block
↓
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.
## Deck Builder Pipeline
The `POST /api/deck-builder/build` endpoint runs 7 steps:
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
## ML Pipeline
### 5. CLI-only usage (no web server)
```powershell
cd D:\ForgeCommander\forge-repo\forge-gui
# Update sim card weights from decision logs (runs automatically in overnight-run.py)
python -m ml.scripts.update_weights --min-games 10 --lr 0.05 --dry-run
java -jar "D:\ForgeCommander\commander-ai-lab\target\commander-ai-lab-1.0.0-SNAPSHOT.jar" `
--forge-jar "..\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar" `
--forge-dir "." `
--deck1 "Edgar Markov" `
--deck2 "Grimgrin" `
--deck3 "Xyris" `
--games 100 `
--threads 4 `
--output results/batch-100.json
# Reset weights to defaults
python -m ml.scripts.update_weights --reset
# Generate synthetic training data
python ml/scripts/generate_synthetic_data.py
```
## Scaling Tests
Learned weights are clamped to `[-20.0, 20.0]` and stored in `src/commander_ai_lab/sim/learned_weights.json`.
### Single-threaded baseline (10 games)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 10 --threads 1 --output results/baseline-10.json
## Python Simulator API
```python
from commander_ai_lab.lab.experiments import build_deck, run_simulation
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt"] + ["Forest"] * 36)
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}")
```
### Multi-threaded (10 games, 2 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 10 --threads 2 --output results/mt-10-t2.json
```
### Scale to 100 games (4 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 4 --output results/scale-100-t4.json
```
### Maximum throughput test (100 games, 8 threads)
```powershell
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar `
--forge-jar "..." --forge-dir "..." `
--deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" `
--games 100 --threads 8 --seed 42 --output results/scale-100-t8-seed42.json
```
### Performance comparison script
Run different thread counts and compare sims/sec:
```powershell
# Baseline: 1 thread
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 1 --seed 42 --output results/perf-t1.json
# 2 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 2 --seed 42 --output results/perf-t2.json
# 4 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 4 --seed 42 --output results/perf-t4.json
# 8 threads
java -jar target\commander-ai-lab-1.0.0-SNAPSHOT.jar --forge-jar "..." --forge-dir "..." --deck1 "Edgar Markov" --deck2 "Grimgrin" --deck3 "Xyris" --games 20 --threads 8 --seed 42 --output results/perf-t8.json
```
Then compare the `simsPerSecond` values in each output JSON to see scaling behavior.
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 a batch run (returns batchId) |
| `/api/lab/status?batchId=X` | GET | Poll progress for a running batch |
| `/api/lab/result?batchId=X` | GET | Get completed batch result JSON |
| `/api/lab/log?batchId=X` | GET | Get live log lines from running batch |
| `/api/lab/history` | GET | List past batch result files |
### POST /api/lab/start
```json
{
"decks": ["Edgar Markov", "Grimgrin", "Xyris"],
"numGames": 100,
"threads": 4,
"seed": null,
"clock": 120
}
```
### GET /api/lab/status response
```json
{
"batchId": "abc123",
"running": true,
"completed": 42,
"total": 100,
"threads": 4,
"elapsedMs": 156000,
"error": null
}
```
## Python Simulator (Monte Carlo Engine)
A pure-Python deck simulator for rapid Monte Carlo analysis — no Forge JVM required.
Ported from the [mtg-commander-lan](https://github.com/) Node.js simulator.
### Location
```
src/commander_ai_lab/
├── __init__.py
├── sim/ # Core simulation engine
│ ├── __init__.py # Public exports
│ ├── models.py # Card, Player, SimState, GameResult, PlayerStats
│ ├── engine.py # GameEngine — turn loop, combat (flying/trample/deathtouch/lifelink/menace/reach)
│ └── rules.py # AI_DEFAULT_WEIGHTS, enrich_card(), score_card(), parse_decklist()
└── lab/ # High-level experiment helpers
├── __init__.py
└── experiments.py # build_deck(), run_single_game(), run_simulation(), _cli_main()
```
### Install as editable package
```powershell
cd D:\ForgeCommander\commander-ai-lab
pip install -e .
```
This installs `commander_ai_lab` as an importable package and registers the `commander-sim` CLI command.
### Quick smoke test
```powershell
python -m commander_ai_lab.lab.experiments
```
Expected output — a 10-game Monte Carlo run of a sample "Korvold" deck:
```
══════════════════════════════════════════
Commander AI Lab — Experiment Runner
══════════════════════════════════════════
--- Demo: Korvold Aristocrats (10 games) ---
Win rate: XX.X%
Avg turns: XX.X
Avg damage dealt: XX.X
Avg spells cast: XX.X
Avg creatures: XX.X
Elapsed: 0.02s
Done.
```
### Python API
```python
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.lab.experiments import build_deck, run_simulation
# Build a deck from card names
deck = build_deck(["Sol Ring", "Cultivate", "Lightning Bolt", "Forest"] + ["Forest"] * 36)
# Monte Carlo simulation (100 games)
stats = run_simulation(deck, num_games=100, name_a="My Deck")
print(f"Win rate: {stats['win_rate']:.1%}")
print(f"Avg turns: {stats['avg_turns']:.1f}")
```
Key functions:
| Function | Description |
|---|---|
| `build_deck(card_names)` | Create enriched `Card` list from names |
| `build_deck_from_text(decklist)` | Parse `"1 Sol Ring\n37 Forest"` format |
| `run_single_game(deck_a, deck_b)` | One game, returns winner/turns/stats |
| `run_simulation(deck_a, deck_b, n)` | Monte Carlo over `n` games, aggregated stats |
### CLI entry point
After `pip install -e .`:
```powershell
commander-sim
```
Runs the same demo as `python -m commander_ai_lab.lab.experiments`.
### How the engine works
1. **Deck enrichment** — `enrich_card()` infers type, CMC, keywords, and power/toughness from card name heuristics when Scryfall data is unavailable.
2. **AI scoring** — `score_card()` uses weighted categories (removal, ramp, draw, aggro, etc.) to pick the best play each turn.
3. **Game loop** — Each turn: untap → draw → play lands → cast spells (AI scored) → combat (attack/block decisions) → end step. Full keyword support for flying, trample, deathtouch, lifelink, menace, and reach.
4. **Monte Carlo** — `run_simulation()` runs N independent games and aggregates win rate, avg turns, damage dealt/received, spells cast, creatures played, removal used, ramp played, cards drawn, and max board size.
| `/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 # Python FastAPI server (web UI backend)
├── overnight-run.py # AFK batch runner
├── start-lab.bat # Windows one-click launcher
├── pom.xml # Maven build (Java 17, shaded JAR)
├── pyproject.toml # Python package config (src/ layout)
├── ui/
│ ├── index.html # Web UI entry point
│ ├── ai-lab.js # UI controller (deck selection, progress, results)
│ ├── ai-lab.css # Styles (dark theme, responsive)
│ ├── collection.html # Card collection manager
│ ├── collection.js # Collection controller
│ ├── collection.css # Collection styles
│ ├── deckbuilder.html # Deck builder + EDH advisor
│ ├── deckbuilder.js # Deck builder controller
│ └── deckbuilder.css # Deck builder styles
├── src/commander_ai_lab/ # Python simulator package
│ ├── __init__.py
│ ├── sim/ # Core engine (models, engine, rules)
│ └── lab/ # Experiment helpers
├── src/main/java/commanderailab/ # Java batch runner (Forge integration)
│ ├── cli/LabCli.java # CLI entry point (picocli)
│ ├── batch/
│ │ ├── BatchRunner.java # Single-thread runner (parses Forge output)
│ │ └── MultiThreadBatchRunner.java # Multi-thread runner (ExecutorService)
│ ├── bridge/WebApiBridge.java # Java-side web API bridge
│ ├── schema/
│ │ ├── BatchResult.java # Data model POJOs
│ │ └── JsonExporter.java # JSON serialization + validation
│ ├── stats/StatsAggregator.java # Win rates, turn counts, breakdowns
│ └── ai/
│ ├── AiPolicy.java # Policy interface
│ └── ForgeBuiltinPolicy.java # v1 Forge AI wrapper
├── scanner/ # Card scanner (OCR pipeline)
│ ├── __init__.py
│ ├── ocr.py # Tesseract OCR wrapper
│ ├── pipeline.py # Image preprocessing pipeline
│ └── preprocess.py # Image preprocessing helpers
├── docs/
│ ├── PROJECT_VISION.md
│ ├── batch-result-schema.json
│ └── game-log-schema.json
├── sample-decks/ # 3 sample .dck files
├── precon-decks/ # Preconstructed decks + index
├── results/ # Batch output JSON files (gitignored)
└── src/test/java/ # Unit tests
├── lab_api.py # FastAPI server entry point
├── frontend/ # React + TypeScript web UI
├── ui/ # Legacy static HTML UI
├── src/commander_ai_lab/
│ ├── sim/ # Monte Carlo engine (engine, 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
```
## Output Format
Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
```json
{
"metadata": {
"schemaVersion": "1.0.0",
"batchId": "uuid",
"timestamp": "2026-03-06T...",
"totalGames": 100,
"completedGames": 100,
"format": "commander",
"podSize": 3,
"engineVersion": "forge-2.0.12-SNAPSHOT",
"masterSeed": 42,
"threads": 4,
"elapsedMs": 260000
},
"decks": [...],
"games": [...],
"summary": {
"perDeck": [
{ "deckName": "Edgar Markov", "winRate": 0.60, "wins": 60, ... }
],
"avgGameTurns": 12.3,
"simsPerSecond": 0.38
}
}
```
## Frontend Development
The UI is a React + TypeScript SPA in the `frontend/` directory.
### Dev server (with hot reload)
```bash
cd frontend
npm install
npm run dev
```
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
### Production build
```bash
cd frontend
npm run build
```
FastAPI serves the built assets from `frontend/dist/`.
## Known Limitations
- Winner's life total not captured (Forge quiet mode only reports losers)
- Win condition classification is heuristic-based on Forge loss reason strings
- Each game spawns a new JVM process (~2-3s overhead per game)
- Max throughput ~0.04 sims/sec single-threaded (Forge sim is CPU-heavy)
- Multithreading spawns parallel subprocesses — throughput scales linearly with threads up to CPU core count
- **Monte Carlo engine** — no stack, no instants, no continuous type changes (animated lands don't attack)
- **Forge engine** — each game spawns a new JVM (~2-3s overhead), ~0.04 sims/sec single-threaded
- **Forge winner life total** not captured (quiet mode only reports losers)
- **Deck builder** — Ollama suggestions are only as good as the local model; `qwen2.5:7b` works well
+1 -1
View File
@@ -1 +1 @@
# Coach clients — Perplexity, LM Studio
# Coach clients — Anthropic Claude, LM Studio
+401
View File
@@ -0,0 +1,401 @@
"""
Commander AI Lab — Anthropic Claude Structured Output Client
════════════════════════════════════════════════════════════
Uses the anthropic SDK to call Claude's messages endpoint
with structured JSON output enforced via system prompt.
Supports both sync and native async operation.
All substantive calls use .messages.stream() to avoid the SDK's
10-minute non-streaming timeout restriction on large max_tokens.
"""
import json
import logging
import re
from typing import Optional
import anthropic
logger = logging.getLogger("coach.anthropic")
# ── Anthropic Models ───────────────────────────────────────────
CLAUDE_OPUS = "claude-opus-4-0-20250514"
CLAUDE_SONNET = "claude-sonnet-4-5"
CLAUDE_HAIKU = "claude-haiku-4-5"
class AnthropicResponse:
"""Parsed response container from Anthropic API.
Drop-in replacement for PerplexityResponse — same attribute shape
so all existing call-sites work without changes.
"""
def __init__(
self,
content: str,
parsed_json: Optional[dict],
prompt_tokens: int,
completion_tokens: int,
model: str,
citations: list[str] = None,
):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
self.completion_tokens = completion_tokens
self.model = model
self.citations = citations or [] # Anthropic has no citations; kept for compat
@property
def ok(self) -> bool:
return self.parsed_json is not None
class AnthropicClient:
"""
Anthropic Claude API client.
Drop-in replacement for PerplexityClient.
All substantive calls use .messages.stream() so the SDK never
enforces the 10-minute non-streaming timeout.
Usage:
client = AnthropicClient(api_key="sk-ant-...")
resp = client.chat_structured(
system_prompt="You are a deck builder.",
user_prompt="Build a deck for...",
json_schema=deck_schema,
schema_name="DeckList",
)
if resp.ok:
deck = resp.parsed_json
"""
def __init__(
self,
api_key: str,
model: str = CLAUDE_OPUS,
timeout: int = 120,
base_url: str = None,
):
if not api_key:
raise ValueError("Anthropic API key is required")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._client = anthropic.Anthropic(
api_key=api_key,
base_url=base_url,
timeout=float(timeout),
)
self._async_client = anthropic.AsyncAnthropic(
api_key=api_key,
base_url=base_url,
timeout=float(timeout),
)
def chat_structured(
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> AnthropicResponse:
"""
Send a chat request expecting JSON output.
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
"""
use_model = model or self.model
effective_system = self._build_system(system_prompt, json_schema, schema_name)
try:
with self._client.messages.stream(
model=use_model,
system=effective_system,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = stream.get_final_text()
final_msg = stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] chat_structured failed: %s", e)
raise
def chat_plain(
self,
system_prompt: str,
user_prompt: str,
model: str = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> AnthropicResponse:
"""
Send a plain chat request without strict structured output.
Uses streaming to avoid the Anthropic SDK 10-minute timeout restriction.
"""
use_model = model or self.model
try:
with self._client.messages.stream(
model=use_model,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = stream.get_final_text()
final_msg = stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] chat_plain failed: %s", e)
raise
async def achat_structured(
self,
system_prompt: str,
user_prompt: str,
json_schema: dict = None,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> AnthropicResponse:
"""Native async structured chat using streaming."""
use_model = model or self.model
effective_system = self._build_system(system_prompt, json_schema, schema_name)
try:
async with self._async_client.messages.stream(
model=use_model,
system=effective_system,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] achat_structured failed: %s", e)
raise
async def achat_plain(
self,
system_prompt: str,
user_prompt: str,
model: str = None,
temperature: float = 0.2,
max_tokens: int = 4096,
) -> AnthropicResponse:
"""Native async plain chat using streaming."""
use_model = model or self.model
try:
async with self._async_client.messages.stream(
model=use_model,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
temperature=temperature,
max_tokens=max_tokens,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
return self._parse_streamed(content, final_msg, use_model)
except Exception as e:
logger.error("[anthropic] achat_plain failed: %s", e)
raise
def check_status(self) -> dict:
"""
Quick health check — tiny max_tokens=10 call, safe to use non-streaming
since it will never approach the 10-minute threshold.
"""
try:
response = self._client.messages.create(
model=self.model,
system="Reply with the word OK.",
messages=[{"role": "user", "content": "Status check."}],
max_tokens=10,
)
content = response.content[0].text if response.content else ""
return {
"connected": True,
"model": self.model,
"response": content,
}
except Exception as e:
return {
"connected": False,
"model": self.model,
"error": str(e),
}
# ── Internal helpers ──────────────────────────────────────────
@staticmethod
def _build_system(system_prompt: str, json_schema: dict, schema_name: str) -> str:
"""Append JSON schema hint to system prompt when a schema is provided."""
if not json_schema:
return system_prompt
schema_str = json.dumps(json_schema, indent=2)
return (
f"{system_prompt}\n\n"
f"You MUST respond with valid JSON only — no markdown fences, no prose.\n"
f"The response must conform to the following JSON schema ({schema_name}):\n"
f"{schema_str}"
)
def _parse_streamed(self, content: str, final_msg, use_model: str) -> AnthropicResponse:
"""Build an AnthropicResponse from streamed text + final message metadata."""
usage = getattr(final_msg, "usage", None)
prompt_tokens = usage.input_tokens if usage else 0
completion_tokens = usage.output_tokens if usage else 0
parsed = None
try:
parsed = json.loads(content)
except (json.JSONDecodeError, TypeError):
parsed = self._extract_json(content)
logger.info(
"[anthropic] model=%s tokens=(%d in, %d out) parsed=%s",
use_model,
prompt_tokens,
completion_tokens,
"yes" if parsed else "no",
)
return AnthropicResponse(
content=content,
parsed_json=parsed,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
model=use_model,
citations=[],
)
@staticmethod
def _extract_json(text: str) -> Optional[dict]:
"""Best-effort JSON extraction from possibly noisy text."""
try:
return json.loads(text.strip())
except (json.JSONDecodeError, TypeError):
pass
# Markdown fences
fence_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text)
if fence_match:
try:
return json.loads(fence_match.group(1).strip())
except (json.JSONDecodeError, TypeError):
pass
# Brace counting
start = text.find('{')
if start == -1:
return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(text)):
ch = text[i]
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1])
except (json.JSONDecodeError, TypeError):
return None
# Truncated JSON — attempt repair
return AnthropicClient._repair_truncated_json(text[start:])
@staticmethod
def _repair_truncated_json(text: str) -> Optional[dict]:
"""
Attempt to repair truncated JSON from max_tokens cutoff.
Strategy: find the last complete nested object, then close
all open brackets/braces.
"""
logger.info("Attempting truncated JSON repair (%d chars)...", len(text))
last_good = -1
depth = 0
in_string = False
escape_next = False
for i in range(len(text)):
ch = text[i]
if escape_next:
escape_next = False
continue
if ch == '\\' and in_string:
escape_next = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth >= 1:
last_good = i
if last_good == -1:
return None
truncated = text[:last_good + 1]
close_depth = 0
in_arr = 0
in_str = False
esc = False
for ch in truncated:
if esc:
esc = False
continue
if ch == '\\' and in_str:
esc = True
continue
if ch == '"':
in_str = not in_str
continue
if in_str:
continue
if ch == '{':
close_depth += 1
elif ch == '}':
close_depth -= 1
elif ch == '[':
in_arr += 1
elif ch == ']':
in_arr -= 1
suffix = ']' * in_arr + '}' * close_depth
repaired = truncated + suffix
try:
result = json.loads(repaired)
card_count = len(result.get('cards', []))
logger.info("JSON repair succeeded: recovered %d cards", card_count)
return result
except (json.JSONDecodeError, TypeError) as e:
logger.warning("JSON repair failed: %s", e)
return None
+343 -51
View File
@@ -9,6 +9,8 @@ calls the LLM, parses responses, and persists coaching sessions.
import json
import logging
import uuid
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict
@@ -16,11 +18,16 @@ from typing import Optional, List, Dict
from .config import (
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
COACH_PROVIDER, ANTHROPIC_API_KEY, ANTHROPIC_MODEL,
UNDERPERFORMER_IMPACT_THRESHOLD, DEFAULT_MAX_TOKENS, ensure_dirs,
)
from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
UpgradePriorityItem, CommanderDependency, MulliganAnalysis,
RemovalCoverage, RampQuality, DrawEngineProfile,
WinCondition, AntiSynergyFlag,
PodPresence, TempoAssessment, MetaMatchup,
)
from .llm_client import LLMClient
from .embeddings import MTGEmbeddingIndex
@@ -28,6 +35,18 @@ from .prompt_template import build_system_prompt, build_user_prompt
logger = logging.getLogger("coach.service")
# SQLite persistence for coach sessions
try:
from services.database import (
save_coach_session as _db_save_session,
load_coach_session as _db_load_session,
list_coach_sessions as _db_list_sessions,
delete_coach_session as _db_delete_session,
)
_DB_AVAILABLE = True
except ImportError:
_DB_AVAILABLE = False
class CoachService:
"""
@@ -46,23 +65,19 @@ class CoachService:
self.embeddings = embeddings
ensure_dirs()
# ── Deck Report I/O ────────────────────────────────────────
# ── Deck Report I/O ────────────────────────────────────
def load_deck_report(self, deck_id: str) -> Optional[DeckReport]:
"""Load a deck report from disk."""
# Try exact filename first, then case-insensitive search
report_path = DECK_REPORTS_DIR / f"{deck_id}.json"
if not report_path.exists():
# Try case-insensitive match
for f in DECK_REPORTS_DIR.glob("*.json"):
if f.stem.lower() == deck_id.lower():
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
report_path = f
break
if not report_path.exists():
logger.warning("Deck report not found: %s", deck_id)
return None
try:
with open(report_path, "r") as f:
data = json.load(f)
@@ -77,18 +92,60 @@ class CoachService:
return []
return [f.stem for f in DECK_REPORTS_DIR.glob("*.json")]
# ── Coach Session I/O ──────────────────────────────────────
# ── Coach Session I/O ──────────────────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk."""
"""Persist a coaching session to disk and SQLite."""
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w") as f:
f.write(session.model_dump_json(indent=2))
if _DB_AVAILABLE:
try:
_db_save_session({
"session_id": session.sessionId,
"deck_id": session.deckId,
"timestamp": session.timestamp,
"model_used": session.modelUsed or "",
"prompt_tokens": session.promptTokens or 0,
"completion_tokens": session.completionTokens or 0,
"summary": session.summary or "",
"goals_json": json.dumps(session.goals.model_dump() if session.goals else {}),
"cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]),
"adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]),
"heuristic_hints_json": json.dumps(session.heuristicHints or []),
"mana_base_advice": session.manaBaseAdvice or "",
"raw_text": session.rawTextExplanation or "",
})
logger.info("Coach session persisted to SQLite: %s", session.sessionId)
except Exception as e:
logger.warning("Failed to persist session to SQLite: %s", e)
return path
def load_session(self, session_id: str) -> Optional[CoachSession]:
"""Load a coaching session from disk."""
"""Load a coaching session from SQLite, falling back to disk."""
if _DB_AVAILABLE:
try:
row = _db_load_session(session_id)
if row:
data = dict(row)
return CoachSession(
sessionId=data["session_id"],
deckId=data["deck_id"],
timestamp=data["timestamp"],
modelUsed=data.get("model_used", ""),
promptTokens=data.get("prompt_tokens", 0),
completionTokens=data.get("completion_tokens", 0),
summary=data.get("summary", ""),
goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None,
suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))],
suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))],
heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")),
manaBaseAdvice=data.get("mana_base_advice", ""),
rawTextExplanation=data.get("raw_text", ""),
)
except Exception as e:
logger.warning("SQLite load failed for %s: %s", session_id, e)
path = COACH_SESSIONS_DIR / f"{session_id}.json"
if not path.exists():
return None
@@ -101,7 +158,14 @@ class CoachService:
return None
def list_sessions(self, deck_id: str = None) -> List[dict]:
"""List all coaching sessions, optionally filtered by deck."""
"""List coaching sessions from SQLite, falling back to disk."""
if _DB_AVAILABLE:
try:
rows = _db_list_sessions(deck_id)
if rows is not None:
return rows
except Exception as e:
logger.warning("SQLite list_sessions failed: %s", e)
if not COACH_SESSIONS_DIR.exists():
return []
sessions = []
@@ -123,23 +187,24 @@ class CoachService:
continue
return sessions
# ── Main Coaching Pipeline ─────────────────────────────────
# ── Main Coaching Pipeline ───────────────────────────────
async def run_coaching_session(self, deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
async def run_coaching_session(
self,
deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
"""
Full coaching pipeline:
1. Load deck report (or use fallback from DB card list)
2. Identify underperformers
3. Find replacement candidates via embeddings
4. Build prompt
5. Call LLM
5. Call LLM (streaming for Anthropic to avoid 10-min SDK timeout)
6. Parse response
7. Persist session
"""
# 0. Auto-load embeddings if not loaded
if not self.embeddings.loaded:
logger.info("Embeddings not loaded — attempting auto-load...")
try:
@@ -151,7 +216,6 @@ class CoachService:
except Exception as e:
logger.warning("Embeddings auto-load failed: %s", e)
# 1. Load deck report — try simulation report first, then fallback
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
@@ -159,25 +223,18 @@ class CoachService:
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# 2. Get deck card names (for exclusion from candidates)
deck_card_names = [c.name for c in report.cards]
# 3. Find replacement candidates for underperformers
candidates: Dict[str, List[dict]] = {}
underperformers = report.underperformers[:MAX_UNDERPERFORMERS]
if not underperformers:
# If no explicit underperformers, pick lowest impact cards
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
underperformers = [
c.name for c in sorted_cards[:MAX_UNDERPERFORMERS]
if c.impactScore < UNDERPERFORMER_IMPACT_THRESHOLD
]
# If still no underperformers and we have cards, pick bottom N by impact
if not underperformers and report.cards:
sorted_cards = sorted(report.cards, key=lambda c: c.impactScore)
# Take the bottom 5 non-land cards as candidates for cuts
underperformers = [
c.name for c in sorted_cards[:5]
if not any(t in (c.tags or []) for t in [])
@@ -195,18 +252,61 @@ class CoachService:
candidates[card_name] = [m.to_dict() for m in matches]
else:
logger.warning("Embeddings not loaded — skipping candidate search")
# Phase 3: RAG vector search for additional card context
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
query_text = f"{report.commander} {' '.join(report.colorIdentity)} commander deck"
rag_cards = query_cards(query_text, n_results=15, color_identity=report.colorIdentity)
logger.info("RAG returned %d cards for '%s'", len(rag_cards), deck_id)
except Exception as e:
logger.warning("RAG query failed (non-fatal): %s", e)
# 4. Build prompts
system_prompt = build_system_prompt(report, goals)
user_prompt = build_user_prompt(report, candidates)
user_prompt = build_user_prompt(report, candidates, rag_cards=rag_cards)
# 5. Call LLM
logger.info("Calling LLM for deck: %s", deck_id)
llm_response = await self.llm.chat(system_prompt, user_prompt)
logger.info("Calling LLM for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured. Set ANTHROPIC_API_KEY env var.")
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
max_tokens=DEFAULT_MAX_TOKENS,
temperature=0.7,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
model=final_msg.model,
prompt_tokens=usage.input_tokens if usage else 0,
completion_tokens=usage.output_tokens if usage else 0,
parsed_json=None,
)
try:
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
llm_response.parsed_json = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
match = re.search(r'\{[\s\S]*\}', content)
if match:
try:
llm_response.parsed_json = json.loads(match.group(0))
except json.JSONDecodeError:
pass
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
# 6. Parse response into CoachSession
session_id = f"sess-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
@@ -220,27 +320,111 @@ class CoachService:
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
# Fallback: store raw text
session.rawTextExplanation = llm_response.content
session.summary = "LLM returned non-JSON response. See raw explanation."
# 7. Persist
self.save_session(session)
logger.info("Coaching session saved: %s", session_id)
return session
# ── Quick Digest ──────────────────────────────────────────
async def run_quick_digest(
self,
deck_id: str,
goals: Optional[CoachGoals] = None,
fallback_report: Optional[DeckReport] = None,
) -> CoachSession:
"""Fast ~400-600 token call: summary + top 3 upgradePriority + commanderDependency."""
report = self.load_deck_report(deck_id)
if report is None and fallback_report is not None:
report = fallback_report
if report is None:
raise ValueError(f"Deck report not found for: {deck_id}")
# Phase 3: light RAG query for quick digest too
rag_cards: List[dict] = []
try:
from services.rag_store import query_cards
query_text = f"{report.commander} {' '.join(report.colorIdentity)}"
rag_cards = query_cards(query_text, n_results=8, color_identity=report.colorIdentity)
except Exception:
pass
from .prompt_template import build_quick_system_prompt, build_user_prompt
system_prompt = build_quick_system_prompt(report, goals)
user_prompt = build_user_prompt(report, {}, rag_cards=rag_cards) # RAG-enriched quick digest # no candidates for speed
logger.info("Quick digest for deck: %s (provider=%s)", deck_id, COACH_PROVIDER)
if COACH_PROVIDER == "anthropic":
import anthropic as _anthropic
anthropic_key = ANTHROPIC_API_KEY
if not anthropic_key:
raise ConnectionError("Anthropic API key not configured.")
aclient = _anthropic.AsyncAnthropic(api_key=anthropic_key)
async with aclient.messages.stream(
model=ANTHROPIC_MODEL,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
max_tokens=1024,
temperature=0.5,
) as stream:
content = await stream.get_final_text()
final_msg = await stream.get_final_message()
usage = getattr(final_msg, "usage", None)
from types import SimpleNamespace
llm_response = SimpleNamespace(
content=content,
model=final_msg.model,
prompt_tokens=usage.input_tokens if usage else 0,
completion_tokens=usage.output_tokens if usage else 0,
parsed_json=None,
)
try:
cleaned = re.sub(r'^```(?:json)?\s*', '', content.strip())
cleaned = re.sub(r'\s*```$', '', cleaned.strip())
llm_response.parsed_json = json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
match = re.search(r'\{[\s\S]*\}', content)
if match:
try:
llm_response.parsed_json = json.loads(match.group(0))
except json.JSONDecodeError:
pass
else:
llm_response = await self.llm.chat(system_prompt, user_prompt)
session_id = f"quick-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
session = CoachSession(
sessionId=session_id,
deckId=deck_id,
timestamp=datetime.now(timezone.utc).isoformat(),
modelUsed=llm_response.model,
promptTokens=llm_response.prompt_tokens,
completionTokens=llm_response.completion_tokens,
goals=goals,
isQuickDigest=True,
)
if llm_response.parsed_json:
self._populate_session_from_json(session, llm_response.parsed_json)
else:
session.rawTextExplanation = llm_response.content
session.summary = "Quick digest returned non-JSON. See raw explanation."
self.save_session(session)
return session
# ── JSON Parser ───────────────────────────────────────────
def _populate_session_from_json(self, session: CoachSession, data: dict):
"""Parse LLM JSON response into CoachSession fields."""
session.summary = data.get("summary", "")
session.rawTextExplanation = data.get("rawTextExplanation", "")
session.manaBaseAdvice = data.get("manaBaseAdvice")
# Heuristic hints
hints = data.get("heuristicHints", [])
session.heuristicHints = hints if isinstance(hints, list) else [str(hints)]
# Suggested cuts
for cut_data in data.get("suggestedCuts", []):
if isinstance(cut_data, dict):
session.suggestedCuts.append(SuggestedCut(
@@ -249,7 +433,6 @@ class CoachService:
replacementOptions=cut_data.get("replacementOptions", []),
))
# Suggested adds
for add_data in data.get("suggestedAdds", []):
if isinstance(add_data, dict):
session.suggestedAdds.append(SuggestedAdd(
@@ -259,25 +442,134 @@ class CoachService:
synergyWith=add_data.get("synergyWith", []),
))
# ── Status Check ───────────────────────────────────────────
# Phase 2
for item in data.get("upgradePriority", []):
if isinstance(item, dict):
session.upgradePriority.append(UpgradePriorityItem(
rank=item.get("rank", 0),
cut=item.get("cut", ""),
add=item.get("add", ""),
reasoning=item.get("reasoning", ""),
expectedImpact=item.get("expectedImpact", ""),
))
cd = data.get("commanderDependency")
if isinstance(cd, dict):
session.commanderDependency = CommanderDependency(
score=cd.get("score", 5),
dependentCards=cd.get("dependentCards", []),
recoveryPlan=cd.get("recoveryPlan", ""),
)
ma = data.get("mulliganAnalysis")
if isinstance(ma, dict):
session.mulliganAnalysis = MulliganAnalysis(
estimatedKeepRate=ma.get("estimatedKeepRate", ""),
worstOffenders=ma.get("worstOffenders", []),
recommendation=ma.get("recommendation", ""),
)
# Phase 3: Removal coverage
rc = data.get("removalCoverage")
if isinstance(rc, dict):
session.removalCoverage = RemovalCoverage(**{
k: rc.get(k, "" if k != "gaps" else [])
for k in ["creatures", "artifacts", "enchantments", "planeswalkers", "lands", "massRemoval", "gaps"]
})
# Phase 3: Ramp quality
rq = data.get("rampQuality")
if isinstance(rq, dict):
session.rampQuality = RampQuality(**{
k: rq.get(k, "") for k in ["manaRocks", "landFetch", "manaDorks", "costReducers", "fragility", "canReachFourByTurnThree"]
})
# Phase 3: Draw engine profile
dep = data.get("drawEngineProfile")
if isinstance(dep, dict):
session.drawEngineProfile = DrawEngineProfile(
burstDraw=dep.get("burstDraw", []),
repeatableEngines=dep.get("repeatableEngines", []),
assessment=dep.get("assessment", ""),
sustainability=dep.get("sustainability", ""),
)
# Phase 3: Win conditions
for wc in data.get("winConditions", []):
if isinstance(wc, dict):
session.winConditions.append(WinCondition(
name=wc.get("name", ""),
cards=wc.get("cards", []),
independence=wc.get("independence", ""),
description=wc.get("description", ""),
))
# Phase 3: Anti-synergy flags
for af in data.get("antiSynergyFlags", []):
if isinstance(af, dict):
session.antiSynergyFlags.append(AntiSynergyFlag(
cards=af.get("cards", []),
conflict=af.get("conflict", ""),
severity=af.get("severity", ""),
))
# Phase 5: Pod presence
pp = data.get("podPresence")
if isinstance(pp, dict):
session.podPresence = PodPresence(
threatLevel=pp.get("threatLevel", ""),
politicalTools=pp.get("politicalTools", []),
adversarialRating=pp.get("adversarialRating", ""),
recommendation=pp.get("recommendation", ""),
)
# Phase 5: Tempo assessment
ta = data.get("tempoAssessment")
if isinstance(ta, dict):
session.tempoAssessment = TempoAssessment(
peakTurn=ta.get("peakTurn", 0),
profile=ta.get("profile", ""),
survivalWindow=ta.get("survivalWindow", ""),
assessment=ta.get("assessment", ""),
)
# Phase 5: Meta matchups
for mm in data.get("metaMatchups", []):
if isinstance(mm, dict):
session.metaMatchups.append(MetaMatchup(
archetype=mm.get("archetype", ""),
assessment=mm.get("assessment", ""),
keyThreats=mm.get("keyThreats", []),
keyAnswers=mm.get("keyAnswers", []),
overallRating=mm.get("overallRating", ""),
))
# ── Status Check ─────────────────────────────────────────
def get_status(self) -> CoachStatus:
"""Check the health of all coach subsystems."""
status = CoachStatus()
# Check LLM
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "LM Studio not reachable")
if COACH_PROVIDER == "anthropic":
anthropic_key = ANTHROPIC_API_KEY
if anthropic_key:
status.llmConnected = True
status.llmModel = f"Claude Opus ({ANTHROPIC_MODEL})"
status.llmModels = [ANTHROPIC_MODEL]
else:
status.llmConnected = False
status.llmModel = None
status.llmModels = []
status.error = "Anthropic API key not configured. Set ANTHROPIC_API_KEY env var."
else:
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "Local LLM not reachable")
# Check embeddings
status.embeddingsLoaded = self.embeddings.loaded
status.embeddingCards = self.embeddings.card_count
# Check deck reports
status.deckReportsAvailable = len(self.list_deck_reports())
return status
+32 -29
View File
@@ -1,6 +1,6 @@
"""
Commander AI Lab — Coach Configuration
═══════════════════════════════════════
────────────────────────────────────────
Central config for Ollama, embeddings, and coach service paths.
All paths are relative to the commander-ai-lab root directory.
"""
@@ -8,57 +8,60 @@ All paths are relative to the commander-ai-lab root directory.
import os
from pathlib import Path
# ── Base Paths ──────────────────────────────────────────────
# Resolve relative to wherever the lab is installed
# ── Base Paths ───────────────────────────────────────────────────────
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── Ollama (OpenAI-compatible API) ──────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
# ── Ollama (OpenAI-compatible API) ────────────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "120")) # seconds
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
# ── Embeddings ─────────────────────────────────────────────
# ── Embeddings ───────────────────────────────────────────────────
EMBEDDINGS_DIR = LAB_ROOT / "data"
EMBEDDINGS_NPZ = EMBEDDINGS_DIR / "mtg-embeddings.npz"
EMBEDDINGS_PARQUET = EMBEDDINGS_DIR / "mtg-embeddings.parquet"
# ── Deck Reports ───────────────────────────────────────────
# ── Deck Reports ────────────────────────────────────────────────
DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
# ── Prompt Limits ──────────────────────────────────────────
MAX_PROMPT_TOKENS = 4096
MAX_CANDIDATES_PER_UNDERPERFORMER = 10
MAX_UNDERPERFORMERS = 8
# ── Prompt Limits ───────────────────────────────────────────────
MAX_PROMPT_TOKENS = 2048
MAX_CANDIDATES_PER_UNDERPERFORMER = 8
MAX_UNDERPERFORMERS = 8
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings (Coach — Ollama) ──────────────
# ── LLM Generation Settings (Coach — Ollama fallback) ─────────────
DEFAULT_TEMPERATURE = 0.7
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
# so we need a generous limit to avoid truncation
DEFAULT_MAX_TOKENS = 8192
DEFAULT_MAX_TOKENS = 16384
# ── Perplexity Deck Generation Settings ───────────────────────
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
# ── Anthropic Claude Deck Generation Settings ──────────────────────
# Provider for deck generation: "anthropic" (Claude) or "local" (Ollama)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "anthropic")
# Model used when DECK_GEN_PROVIDER="anthropic"
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "claude-opus-4-20250514")
# Anthropic API key
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
# Anthropic model for deck generation and coaching
ANTHROPIC_MODEL = os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-20250514")
# Coach chat provider: "anthropic" (Claude API) or "local" (Ollama)
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "anthropic")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
# ── Smart Substitution Settings ───────────────────────────────
# Minimum embedding similarity to accept a substitute without Perplexity fallback
# ── Smart Substitution Settings ───────────────────────────────────
# Minimum embedding similarity to accept a substitute without Anthropic fallback
SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY", "0.75"))
# Maximum number of alternatives to suggest per missing card
SUBSTITUTION_MAX_ALTERNATIVES = int(os.environ.get("SUBSTITUTION_MAX_ALTERNATIVES", "5"))
# Model for substitution fallback (sonar is fine — small focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "gpt-oss:20b")
# Enable Perplexity fallback for low-confidence embedding matches
SUBSTITUTION_USE_PPLX_FALLBACK = os.environ.get("SUBSTITUTION_USE_PPLX_FALLBACK", "true").lower() == "true"
# Model for substitution fallback (claude-haiku-4-5 is fast + cheap for focused queries)
SUBSTITUTION_MODEL = os.environ.get("SUBSTITUTION_MODEL", "claude-haiku-4-5")
# Enable Anthropic fallback for low-confidence embedding matches
SUBSTITUTION_USE_ANTHROPIC_FALLBACK = os.environ.get("SUBSTITUTION_USE_ANTHROPIC_FALLBACK", "true").lower() == "true"
# ── Ensure directories exist ───────────────────────────────
# ── Ensure directories exist ─────────────────────────────────────
def ensure_dirs():
"""Create required directories if they don't exist."""
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
+229 -41
View File
@@ -9,6 +9,11 @@ Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
Model: Alibaba-NLP/gte-modernbert-base
Unknown cards (e.g. newly printed sets) are fetched from Scryfall,
embedded on-the-fly using the same model, and persisted to a local
custom cache (embeddings/mtg_embeddings_custom.npz) so they survive
restarts without re-hitting Scryfall.
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
"""
@@ -19,9 +24,11 @@ import os
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from urllib.parse import quote
import numpy as np
from services.card_text import build_card_text
from .config import EMBEDDINGS_DIR, EMBEDDINGS_NPZ, EMBEDDINGS_PARQUET
logger = logging.getLogger("coach.embeddings")
@@ -32,8 +39,16 @@ HF_PARQUET_URL = (
"resolve/main/mtg_embeddings.parquet"
)
# ── Scryfall API ───────────────────────────────────────────
SCRYFALL_NAMED_URL = "https://api.scryfall.com/cards/named?fuzzy={}"
# ── Custom cache for on-the-fly embedded cards ─────────────
CUSTOM_CACHE_PATH = EMBEDDINGS_DIR / "mtg_embeddings_custom.npz"
# ── Embedding model (same as minimaxir/mtg-embeddings) ─────
EMBEDDING_MODEL = "Alibaba-NLP/gte-modernbert-base"
# ── Color identity parsing ─────────────────────────────────
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
MANA_SYMBOL_COLORS = {
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
}
@@ -46,7 +61,6 @@ def _parse_color_identity(mana_cost: str) -> str:
colors = set()
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
colors.add(match.group(1))
# Sort in WUBRG order
order = "WUBRG"
return "".join(c for c in order if c in colors)
@@ -61,12 +75,11 @@ def _parse_mana_value(mana_cost: str) -> float:
if symbol in MANA_SYMBOL_COLORS:
total += 1.0
elif symbol == "X":
pass # X = 0 for CMC purposes
pass
else:
try:
total += float(symbol)
except ValueError:
# Hybrid/phyrexian symbols count as 1
total += 1.0
return total
@@ -75,7 +88,35 @@ def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
"""Check if card's color identity is within deck's color identity."""
deck_set = set(deck_colors)
card_set = set(card_colors)
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
return card_set.issubset(deck_set) or len(card_set) == 0
def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
"""Fetch card data from Scryfall by fuzzy name match.
Returns a dict with keys: name, mana_cost, type_line, oracle_text,
color_identity. Returns None on any error.
"""
url = SCRYFALL_NAMED_URL.format(quote(card_name))
try:
req = Request(url, headers={"User-Agent": "commander-ai-lab/1.0"})
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
return {
"name": data.get("name", card_name),
"mana_cost": data.get("mana_cost", ""),
"type_line": data.get("type_line", ""),
"oracle_text": data.get("oracle_text", ""),
"color_identity": "".join(data.get("color_identity", [])),
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", card_name, e)
return None
def _build_card_text(card: dict) -> str:
"""Delegate to shared build_card_text() -- see services/card_text.py."""
return build_card_text(card)
class CardMatch:
@@ -114,15 +155,17 @@ class MTGEmbeddingIndex:
"""
def __init__(self):
self.names: np.ndarray = None # (N,) string array
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
self.mana_values: np.ndarray = None # (N,) float array
self.types: np.ndarray = None # (N,) string array
self.mana_costs: np.ndarray = None # (N,) string array
self.texts: np.ndarray = None # (N,) string array
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
self.names: np.ndarray = None
self.colors: np.ndarray = None
self.mana_values: np.ndarray = None
self.types: np.ndarray = None
self.mana_costs: np.ndarray = None
self.texts: np.ndarray = None
self.vectors: np.ndarray = None
self._name_to_idx: dict = {}
self._loaded = False
self._sentence_model = None # lazy-loaded only when needed
self._custom_dirty = False # tracks whether custom cache needs saving
@property
def loaded(self) -> bool:
@@ -152,10 +195,7 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_PARQUET
def convert_parquet_to_npz(self) -> Path:
"""
Convert the Parquet file to a compact NPZ for fast loading.
Requires pandas and pyarrow.
"""
"""Convert the Parquet file to a compact NPZ for fast loading."""
try:
import pandas as pd
except ImportError:
@@ -169,14 +209,11 @@ class MTGEmbeddingIndex:
types = df["type"].fillna("").values.astype(str)
texts = df["text"].fillna("").values.astype(str)
# Parse color identities from mana costs
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
# Extract embedding vectors
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
# L2 normalize for cosine similarity via dot product
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1.0
embeddings = embeddings / norms
@@ -198,25 +235,27 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_NPZ
def load(self, force_download: bool = False) -> bool:
"""
Load embeddings from NPZ (fast) or fall back to downloading
"""Load embeddings from NPZ (fast) or fall back to downloading
and converting from Parquet.
"""
if self._loaded and not force_download:
return True
# Try NPZ first (fast path)
if EMBEDDINGS_NPZ.exists() and not force_download:
return self._load_npz()
ok = self._load_npz()
else:
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
ok = self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
# Download and convert
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
return self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
if ok:
self._load_custom_cache()
return ok
def _load_npz(self) -> bool:
"""Load from pre-built NPZ file."""
@@ -230,7 +269,6 @@ class MTGEmbeddingIndex:
self.texts = data.get("texts", np.array([""] * len(self.names)))
self.vectors = data["vectors"]
# Build name lookup
self._name_to_idx = {
name.lower(): i for i, name in enumerate(self.names)
}
@@ -241,12 +279,168 @@ class MTGEmbeddingIndex:
logger.error("Failed to load NPZ: %s", e)
return False
# ── Custom cache ──────────────────────────────────────────
def _load_custom_cache(self) -> None:
"""Merge on-the-fly embedded cards from the custom cache into the index."""
if not CUSTOM_CACHE_PATH.exists():
return
try:
data = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
c_names = data["names"]
# Only add cards that aren't already in the main index
new_mask = np.array(
[n.lower() not in self._name_to_idx for n in c_names]
)
if not new_mask.any():
return
self._append_cards(
c_names[new_mask],
data["colors"][new_mask],
data["mana_values"][new_mask],
data["types"][new_mask],
data["mana_costs"][new_mask],
data["texts"][new_mask],
data["vectors"][new_mask],
)
logger.info("Loaded %d custom-cached card embeddings", new_mask.sum())
except Exception as e:
logger.warning("Failed to load custom embedding cache: %s", e)
def _save_custom_cache(self) -> None:
"""Persist the custom cache NPZ (only cards not in the base dataset)."""
if not self._custom_dirty:
return
try:
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
# Collect indices that correspond to custom-added cards
# We track these by loading the base count at load time — simpler:
# just re-save the full custom cache from CUSTOM_CACHE_PATH if it
# exists, merging with any new cards we've added this session.
existing_names: set = set()
existing: dict = {}
if CUSTOM_CACHE_PATH.exists():
try:
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
for n in d["names"]:
existing_names.add(n.lower())
except Exception:
pass
# Find names in our live index that aren't in the base NPZ
base_data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
base_names = set(n.lower() for n in base_data["names"])
custom_mask = np.array(
[n.lower() not in base_names for n in self.names]
)
if not custom_mask.any():
return
np.savez_compressed(
str(CUSTOM_CACHE_PATH),
names=self.names[custom_mask],
colors=self.colors[custom_mask],
mana_values=self.mana_values[custom_mask],
types=self.types[custom_mask],
mana_costs=self.mana_costs[custom_mask],
texts=self.texts[custom_mask],
vectors=self.vectors[custom_mask],
)
self._custom_dirty = False
logger.info("Saved custom embedding cache: %d cards", custom_mask.sum())
except Exception as e:
logger.warning("Failed to save custom embedding cache: %s", e)
def _append_cards(
self,
names: np.ndarray,
colors: np.ndarray,
mana_values: np.ndarray,
types: np.ndarray,
mana_costs: np.ndarray,
texts: np.ndarray,
vectors: np.ndarray,
) -> None:
"""Append new card arrays to the live in-memory index."""
start_idx = len(self.names)
self.names = np.concatenate([self.names, names])
self.colors = np.concatenate([self.colors, colors])
self.mana_values = np.concatenate([self.mana_values, mana_values])
self.types = np.concatenate([self.types, types])
self.mana_costs = np.concatenate([self.mana_costs, mana_costs])
self.texts = np.concatenate([self.texts, texts])
self.vectors = np.concatenate([self.vectors, vectors], axis=0)
for i, name in enumerate(names):
self._name_to_idx[name.lower()] = start_idx + i
# ── Sentence model (lazy) ─────────────────────────────────
def _get_sentence_model(self):
"""Lazy-load the sentence-transformers model on first cache miss."""
if self._sentence_model is None:
try:
from sentence_transformers import SentenceTransformer
logger.info("Loading embedding model %s (first cache miss)...", EMBEDDING_MODEL)
self._sentence_model = SentenceTransformer(EMBEDDING_MODEL)
logger.info("Embedding model loaded.")
except Exception as e:
logger.error("Failed to load SentenceTransformer: %s", e)
return None
return self._sentence_model
# ── On-the-fly Scryfall fallback ──────────────────────────
def _fetch_and_embed_card(self, card_name: str) -> Optional[np.ndarray]:
"""Fetch card from Scryfall, embed it, append to index, persist to cache.
Returns the L2-normalized embedding vector on success, None on failure.
"""
logger.info("Fetching unknown card from Scryfall: '%s'", card_name)
card_data = _fetch_scryfall_card(card_name)
if card_data is None:
return None
model = self._get_sentence_model()
if model is None:
return None
text = _build_card_text(card_data)
vec = model.encode(text, normalize_embeddings=True).astype(np.float32)
# Ensure shape (1, 768)
vec = vec.reshape(1, -1)
resolved_name = card_data["name"]
mana_cost = card_data.get("mana_cost", "")
color_identity = card_data.get("color_identity", "") or _parse_color_identity(mana_cost)
self._append_cards(
names=np.array([resolved_name]),
colors=np.array([color_identity]),
mana_values=np.array([_parse_mana_value(mana_cost)], dtype=np.float32),
types=np.array([card_data.get("type_line", "")]),
mana_costs=np.array([mana_cost]),
texts=np.array([card_data.get("oracle_text", "")]),
vectors=vec,
)
self._custom_dirty = True
self._save_custom_cache()
logger.info("Embedded and cached new card: '%s'", resolved_name)
return self.vectors[self._name_to_idx[resolved_name.lower()]]
# ── Public API ────────────────────────────────────────────
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
"""Get the embedding vector for a card by name (case-insensitive)."""
"""Get the embedding vector for a card by name (case-insensitive).
On a cache miss, fetches from Scryfall and embeds on the fly.
"""
idx = self._name_to_idx.get(card_name.lower())
if idx is not None:
return self.vectors[idx]
return None
# Cache miss — try Scryfall fallback
return self._fetch_and_embed_card(card_name)
def search_similar(self, query_card: str,
color_filter: List[str] = None,
@@ -274,38 +468,32 @@ class MTGEmbeddingIndex:
query_vec = self.get_card_vector(query_card)
if query_vec is None:
logger.warning("Card not found in embeddings: %s", query_card)
logger.warning("Card not found in embeddings and Scryfall fallback failed: %s", query_card)
return []
# Compute similarities (dot product on normalized vectors = cosine similarity)
similarities = self.vectors @ query_vec
# Build exclusion set
exclude_set = set()
exclude_set.add(query_card.lower())
if exclude_cards:
exclude_set.update(name.lower() for name in exclude_cards)
# Filter and collect results
results = []
for idx in np.argsort(similarities)[::-1]:
name = str(self.names[idx])
if name.lower() in exclude_set:
continue
# Color filter
if color_filter:
card_colors = str(self.colors[idx])
if not _is_color_subset(card_colors, color_filter):
continue
# Type filter
if type_filter:
card_type = str(self.types[idx])
if type_filter.lower() not in card_type.lower():
continue
# Mana value range filter
if mana_range:
mv = float(self.mana_values[idx])
if mv < mana_range[0] or mv > mana_range[1]:
+127 -130
View File
@@ -1,22 +1,25 @@
"""
Commander AI Lab — Ollama Client
Commander AI Lab — Ollama LLM Client
════════════════════════════════════
Async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
Fully async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Every network call uses
httpx.AsyncClient so it NEVER blocks the FastAPI event loop.
"""
from __future__ import annotations
import json
import logging
import asyncio
from typing import Optional
from urllib.request import urlopen, Request
from urllib.error import URLError
import httpx
from .config import (
LLM_URL, LLM_MODEL,
LLM_TIMEOUT, LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
LLM_URL,
LLM_MODEL,
LLM_TIMEOUT,
LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE,
DEFAULT_MAX_TOKENS,
)
logger = logging.getLogger("coach.llm")
@@ -24,9 +27,15 @@ logger = logging.getLogger("coach.llm")
class LLMResponse:
"""Parsed LLM response container."""
def __init__(self, content: str, parsed_json: Optional[dict],
prompt_tokens: int, completion_tokens: int,
model: str):
def __init__(
self,
content: str,
parsed_json: Optional[dict],
prompt_tokens: int,
completion_tokens: int,
model: str,
):
self.content = content
self.parsed_json = parsed_json
self.prompt_tokens = prompt_tokens
@@ -36,7 +45,7 @@ class LLMResponse:
class LLMClient:
"""
Client for Ollama's OpenAI-compatible API.
Fully async client for Ollama's OpenAI-compatible API.
Usage:
client = LLMClient()
@@ -45,19 +54,25 @@ class LLMClient:
suggestions = response.parsed_json
"""
def __init__(self, base_url: str = None, model: str = None,
timeout: int = None):
def __init__(
self,
base_url: str = None,
model: str = None,
timeout: int = None,
):
self.base_url = (base_url or LLM_URL).rstrip("/")
self.model = model or LLM_MODEL
self.timeout = timeout or LLM_TIMEOUT
self._resolved_model = None # actual model name from /models endpoint
self._resolved_model: Optional[str] = None
def _resolve_model(self) -> str:
# ── Model resolution ───────────────────────────────────────
async def _resolve_model(self) -> str:
"""Get the actual loaded model name from Ollama /models endpoint."""
if self._resolved_model:
return self._resolved_model
try:
status = self.check_connection()
status = await self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved Ollama model: %s", self._resolved_model)
@@ -66,11 +81,18 @@ class LLMClient:
pass
return self.model
def _build_request_body(self, system_prompt: str, user_prompt: str,
temperature: float, max_tokens: int,
force_json: bool) -> dict:
model_name = self._resolve_model()
body = {
# ── Request building ──────────────────────────────────────
async def _build_request_body(
self,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: int,
force_json: bool,
) -> dict:
model_name = await self._resolve_model()
body: dict = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
@@ -79,32 +101,27 @@ class LLMClient:
"temperature": temperature,
"max_tokens": max_tokens,
}
# Only request JSON format if using a model that supports it
# Some models (esp. smaller ones) don't support response_format
if force_json:
body["response_format"] = {"type": "json_object"}
return body
# ── Response parsing ──────────────────────────────────────
@staticmethod
def _strip_think_tags(text: str) -> str:
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks from LLM output."""
"""Remove DeepSeek-R1 <think>...</think> reasoning blocks."""
import re
# Remove all <think>...</think> blocks (greedy, handles multiline)
cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
return cleaned.strip()
return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
@staticmethod
def _extract_json_object(text: str) -> Optional[dict]:
"""Find and parse the first complete JSON object in text.
Uses brace-counting to handle nested objects reliably."""
"""Find and parse the first complete JSON object via brace-counting."""
start = text.find('{')
if start == -1:
return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(text)):
ch = text[i]
if escape_next:
@@ -124,66 +141,56 @@ class LLMClient:
elif ch == '}':
depth -= 1
if depth == 0:
candidate = text[start:i+1]
candidate = text[start : i + 1]
try:
return json.loads(candidate)
except (json.JSONDecodeError, ValueError):
# Malformed — try next opening brace
next_start = text.find('{', start + 1)
if next_start != -1 and next_start < i:
start = next_start
depth = 1 # we're already past this brace
return None
depth = 1
return None
return None
def _parse_response(self, raw: dict) -> LLMResponse:
"""Extract content and usage from OpenAI-format response.
Handles DeepSeek-R1 <think> tags, markdown fences, and raw JSON."""
"""Extract content and usage from an OpenAI-format response."""
choice = raw.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
usage = raw.get("usage", {})
# Step 1: Strip <think>...</think> blocks (DeepSeek-R1)
cleaned = self._strip_think_tags(content)
# Step 2: Try direct JSON parse on cleaned content
parsed = None
parsed: Optional[dict] = None
# 1. Direct JSON parse
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, TypeError):
pass
# Step 3: Try extracting from markdown fences
# 2. Markdown fences
if parsed is None and "```json" in cleaned:
try:
start = cleaned.index("```json") + 7
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
s = cleaned.index("```json") + 7
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
if parsed is None and "```" in cleaned:
try:
start = cleaned.index("```") + 3
end = cleaned.index("```", start)
parsed = json.loads(cleaned[start:end].strip())
s = cleaned.index("```") + 3
e = cleaned.index("```", s)
parsed = json.loads(cleaned[s:e].strip())
except (json.JSONDecodeError, ValueError, IndexError):
pass
# Step 4: Brute-force — find the first valid JSON object in the text
# 3. Brace-counting extraction
if parsed is None:
parsed = self._extract_json_object(cleaned)
# Step 5: If still nothing, try on the original content (in case
# think-tag stripping was too aggressive)
# 4. Fallback on original content
if parsed is None and content != cleaned:
parsed = self._extract_json_object(content)
if parsed is not None:
logger.info("Successfully parsed JSON from LLM response")
else:
logger.warning("Could not extract JSON from LLM response (%d chars)",
len(content))
logger.warning(
"Could not extract JSON from LLM response (%d chars)", len(content)
)
return LLMResponse(
content=content,
parsed_json=parsed,
@@ -192,94 +199,84 @@ class LLMClient:
model=raw.get("model", self.model),
)
def chat_sync(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
# ── Public API ─────────────────────────────────────────────
async def chat(
self,
system_prompt: str,
user_prompt: str,
temperature: float = None,
max_tokens: int = None,
force_json: bool = True,
) -> LLMResponse:
"""
Synchronous chat call to Ollama.
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
Async chat call to Ollama. Retries up to LLM_MAX_RETRIES.
Never blocks the event loop — uses httpx.AsyncClient throughout.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
body = self._build_request_body(system_prompt, user_prompt,
temp, tokens, force_json)
last_error = None
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, force_json
)
last_error: Optional[Exception] = None
for attempt in range(1, LLM_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LLM_MAX_RETRIES)
req = Request(
f"{self.base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(req, timeout=self.timeout) as resp:
raw = json.loads(resp.read().decode("utf-8"))
async with httpx.AsyncClient(
timeout=httpx.Timeout(float(self.timeout))
) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
json=body,
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
raw = resp.json()
result = self._parse_response(raw)
# If we need JSON and didn't get it, retry
if force_json and result.parsed_json is None:
logger.warning("Attempt %d: LLM returned non-JSON, retrying", attempt)
logger.warning(
"Attempt %d: LLM returned non-JSON, retrying", attempt
)
last_error = ValueError("Non-JSON response from LLM")
continue
return result
except httpx.HTTPStatusError as e:
logger.warning("Attempt %d HTTP error %s", attempt, e.response.status_code)
last_error = e
# 400 with json format → retry without response_format constraint
if e.response.status_code == 400 and force_json:
logger.warning("Got 400 — retrying without response_format")
force_json = False
body = await self._build_request_body(
system_prompt, user_prompt, temp, tokens, False
)
except Exception as e:
logger.warning("Attempt %d failed: %s", attempt, e)
last_error = e
# If 400 and we used json response_format, retry without it
error_str = str(e)
if "400" in error_str and force_json:
logger.warning("Got 400 — retrying without response_format constraint")
force_json = False
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LLM_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
if attempt < LLM_MAX_RETRIES:
import asyncio
await asyncio.sleep(2 * attempt)
raise ConnectionError(
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
)
async def chat(self, system_prompt: str, user_prompt: str,
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Async chat call — runs the sync call in a thread executor
so it doesn't block the FastAPI event loop.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self.chat_sync(system_prompt, user_prompt,
temperature, max_tokens, force_json)
)
def check_connection(self) -> dict:
"""
Check if Ollama is reachable. Returns status dict.
"""
async def check_connection(self) -> dict:
"""Check if Ollama is reachable. Returns status dict."""
try:
req = Request(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
method="GET",
)
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
resp = await client.get(
f"{self.base_url}/models",
headers={"Content-Type": "application/json"},
)
resp.raise_for_status()
data = resp.json()
models = data.get("data", [])
model_ids = [m.get("id", "unknown") for m in models]
return {
"connected": True,
"models": model_ids,
"active_model": model_ids[0] if model_ids else self.model,
}
except Exception as e:
return {
"connected": False,
+140 -17
View File
@@ -4,12 +4,9 @@ Commander AI Lab — Coach Data Models
Pydantic v2 models for deck reports, coach requests/responses,
and coaching session persistence.
"""
from datetime import datetime
from typing import List, Optional, Dict
from pydantic import BaseModel, Field
# ══════════════════════════════════════════════════════════════
# Deck Report Models (mirrors Java DeckReport)
# ══════════════════════════════════════════════════════════════
@@ -17,16 +14,16 @@ from pydantic import BaseModel, Field
class CardPerformance(BaseModel):
"""Per-card aggregated stats from simulation data."""
name: str
drawnRate: float = 0.0 # Fraction of games where card was drawn
castRate: float = 0.0 # Fraction of games where drawn AND cast
drawnRate: float = 0.0
castRate: float = 0.0
keptInOpeningHandRate: float = 0.0
deadCardRate: float = 0.0 # Drawn but never cast/used
impactScore: float = 0.0 # (winRateWhenCast - overallWinRate) * castRate
synergyScore: float = 0.0 # Co-occurrence uplift with other deck cards
clunkinessScore: float = 0.0 # deadCardRate * (1 - castRate)
deadCardRate: float = 0.0
impactScore: float = 0.0
synergyScore: float = 0.0
clunkinessScore: float = 0.0
avgTurnCast: Optional[float] = None
avgDamageDealt: float = 0.0
tags: List[str] = Field(default_factory=list) # ramp, draw, removal, etc.
tags: List[str] = Field(default_factory=list)
class MatchupRecord(BaseModel):
@@ -48,7 +45,7 @@ class DeckMeta(BaseModel):
class DeckStructure(BaseModel):
"""Deck composition breakdown."""
landCount: int = 0
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8) # CMC 0-7+
curveBuckets: List[int] = Field(default_factory=lambda: [0]*8)
cardTypeCounts: Dict[str, int] = Field(default_factory=dict)
functionalCounts: Dict[str, int] = Field(default_factory=dict)
@@ -57,7 +54,7 @@ class ComboRecord(BaseModel):
"""Known combo with performance data."""
cardNames: List[str]
winRateWhenAssembled: float = 0.0
assemblyRate: float = 0.0 # Fraction of games where all pieces drawn
assemblyRate: float = 0.0
class DeckReport(BaseModel):
@@ -84,10 +81,10 @@ class DeckReport(BaseModel):
class CoachGoals(BaseModel):
"""User-specified coaching goals."""
targetPowerLevel: Optional[int] = None # 1-10
metaFocus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focusAreas: List[str] = Field(default_factory=list) # e.g., ["ramp", "card draw"]
targetPowerLevel: Optional[int] = None
metaFocus: Optional[str] = None
budget: Optional[str] = None
focusAreas: List[str] = Field(default_factory=list)
class CoachRequest(BaseModel):
@@ -107,12 +104,120 @@ class SuggestedCut(BaseModel):
class SuggestedAdd(BaseModel):
"""A card the coach suggests adding."""
cardName: str
role: str = "" # ramp, draw, removal, finisher, etc.
role: str = ""
reason: str = ""
synergyWith: List[str] = Field(default_factory=list)
estimatedManaValue: Optional[float] = None
# ══════════════════════════════════════════════════════════════
# Phase 2: Structured Analysis Models
# ══════════════════════════════════════════════════════════════
class UpgradePriorityItem(BaseModel):
"""A single prioritized cut/add swap ranked by expected impact."""
rank: int
cut: str
add: str
reasoning: str = ""
expectedImpact: str = ""
class CommanderDependency(BaseModel):
"""How reliant the deck is on having the commander in play."""
score: int = 5
dependentCards: List[str] = Field(default_factory=list)
recoveryPlan: str = ""
class MulliganAnalysis(BaseModel):
"""Opening hand keepability assessment."""
estimatedKeepRate: str = ""
worstOffenders: List[str] = Field(default_factory=list)
recommendation: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 3: Qualitative Analysis Models
# ══════════════════════════════════════════════════════════════
class RemovalCoverage(BaseModel):
"""Categorized removal package by permanent type."""
creatures: str = ""
artifacts: str = ""
enchantments: str = ""
planeswalkers: str = ""
lands: str = ""
massRemoval: str = ""
gaps: List[str] = Field(default_factory=list)
class RampQuality(BaseModel):
"""Ramp package breakdown by subtype and fragility."""
manaRocks: str = ""
landFetch: str = ""
manaDorks: str = ""
costReducers: str = ""
fragility: str = ""
canReachFourByTurnThree: str = ""
class DrawEngineProfile(BaseModel):
"""Card draw sustainability analysis."""
burstDraw: List[str] = Field(default_factory=list)
repeatableEngines: List[str] = Field(default_factory=list)
assessment: str = ""
sustainability: str = ""
class WinCondition(BaseModel):
"""A named win condition with independence rating."""
name: str
cards: List[str] = Field(default_factory=list)
independence: str = ""
description: str = ""
class AntiSynergyFlag(BaseModel):
"""An internal conflict between cards in the deck."""
cards: List[str] = Field(default_factory=list)
conflict: str = ""
severity: str = ""
# ══════════════════════════════════════════════════════════════
# Phase 5: Pod Politics & Meta Awareness Models
# ══════════════════════════════════════════════════════════════
class PodPresence(BaseModel):
"""Commander pod politics and threat assessment."""
threatLevel: str = "" # early/medium/late/low
politicalTools: List[str] = Field(default_factory=list)
adversarialRating: str = "" # low/medium/high
recommendation: str = ""
class TempoAssessment(BaseModel):
"""When the deck 'comes online' and whether that's fast enough."""
peakTurn: int = 0
profile: str = "" # early/mid/late
survivalWindow: str = "" # Assessment of whether it survives to peak
assessment: str = ""
class MetaMatchup(BaseModel):
"""Strategic reasoning about deck vs a meta archetype."""
archetype: str # fast-combo, stax, control, aggro, etc.
assessment: str = ""
keyThreats: List[str] = Field(default_factory=list)
keyAnswers: List[str] = Field(default_factory=list)
overallRating: str = "" # favored/even/unfavored
# ══════════════════════════════════════════════════════════════
# Coach Session (persisted result)
# ══════════════════════════════════════════════════════════════
class CoachSession(BaseModel):
"""
Complete coaching session result.
@@ -131,6 +236,24 @@ class CoachSession(BaseModel):
promptTokens: int = 0
completionTokens: int = 0
goals: Optional[CoachGoals] = None
isQuickDigest: bool = False # True for quick digest, False for full analysis
# Phase 2: Structured analysis fields
upgradePriority: List[UpgradePriorityItem] = Field(default_factory=list)
commanderDependency: Optional[CommanderDependency] = None
mulliganAnalysis: Optional[MulliganAnalysis] = None
# Phase 3: Qualitative analysis fields
removalCoverage: Optional[RemovalCoverage] = None
rampQuality: Optional[RampQuality] = None
drawEngineProfile: Optional[DrawEngineProfile] = None
winConditions: List[WinCondition] = Field(default_factory=list)
antiSynergyFlags: List[AntiSynergyFlag] = Field(default_factory=list)
# Phase 5: Pod politics & meta
podPresence: Optional[PodPresence] = None
tempoAssessment: Optional[TempoAssessment] = None
metaMatchups: List[MetaMatchup] = Field(default_factory=list)
class ApplyRequest(BaseModel):
+336 -130
View File
@@ -6,7 +6,6 @@ Injects deck report data, underperformer analysis, and
candidate replacement cards into a structured template.
"""
import json
from typing import List, Dict, Optional
from .models import DeckReport, CoachGoals, CardPerformance
@@ -27,40 +26,186 @@ RULES:
- Base suggestions on the simulation data provided, not general "goodstuff" advice.
- Consider mana curve, role balance (ramp, draw, removal, threats), and synergy.
- When suggesting replacements, prefer cards that fill the same functional role.
- IMPORTANT: Suggest 3-5 DIFFERENT cards to cut and 3-5 DIFFERENT cards to add.
- IMPORTANT: Suggest 5-8 DIFFERENT cards to cut and 5-8 DIFFERENT cards to add.
- Each suggested add MUST be a different card — never suggest the same card twice.
- Vary your suggestions across different functional roles (ramp, draw, removal, threats, utility).
- If the deck is missing a functional role (e.g., no card draw, no removal), prioritize adding cards for that role.
- If candidate replacements are provided from embeddings search, prefer those over generic suggestions.
- For each suggested cut, provide a DETAILED reason citing specific stats (impact score, cast rate, dead card rate, clunkiness, synergy score).
- For each suggested add, explain the SPECIFIC synergy with existing cards in the deck.
- When reasoning about cuts, also consider keptInOpeningHandRate (low = bad in opening hands) and avgTurnCast (high = too slow for the curve).
- When evaluating synergy, use the synergyScore: cards below 0.1 have weak co-occurrence with the rest of the deck.
- Use perArchetypeWinRates to identify which matchup types this deck struggles with and tailor suggestions accordingly.
- Provide at least 5 heuristic hints covering different strategic dimensions.
- Assess how dependent the deck is on the commander being in play.
- Evaluate opening hand quality and identify cards that cause bad mulligans.
- Rank your suggested swaps by priority so the user knows what to change first.
- Categorize the removal package by permanent type and flag coverage gaps.
- Evaluate the ramp package for fragility (creature/artifact-heavy vs land-based).
- Distinguish one-time draw from repeatable draw engines.
- Identify the top 3 win conditions and rate each for independence.
- Flag internal anti-synergies or card conflicts.
- Assess pod threat perception: does this deck draw too much hate too early?
- Evaluate when the deck comes online (tempo profile) and whether that's viable.
- Reason about matchups vs fast combo, stax/prison, and control archetypes.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
{{
"summary": "2-3 sentence overview of the deck's strengths and weaknesses",
"summary": "4-6 sentence detailed overview of the deck's strengths, weaknesses, key synergies, and strategic identity. Reference specific win rates and simulation data.",
"suggestedCuts": [
{{
"cardName": "Card To Remove",
"reason": "Why this card underperforms",
"replacementOptions": ["Better Card 1", "Better Card 2"]
"reason": "Detailed explanation citing specific simulation stats (impact score, cast rate, dead card rate, clunkiness score, synergy score, avg turn cast) and why this card underperforms in this specific deck context",
"replacementOptions": ["Better Card 1", "Better Card 2"],
"functionalRole": "ramp|draw|removal|threat|utility|combo|protection",
"estimatedImpact": "How cutting this card is expected to improve the deck"
}}
],
"suggestedAdds": [
{{
"cardName": "Card To Add",
"role": "ramp|draw|removal|threat|utility|combo|protection",
"reason": "Why this card improves the deck",
"synergyWith": ["Card In Deck 1", "Card In Deck 2"]
"reason": "Detailed explanation of why this card improves the deck, referencing specific weaknesses it addresses",
"synergyWith": ["Card In Deck 1", "Card In Deck 2"],
"estimatedManaCost": "The mana value of the suggested card",
"strategicRationale": "How this card fits into the overall game plan and addresses a specific gap"
}}
],
"heuristicHints": [
"Concise strategic tip based on data"
"upgradePriority": [
{{
"rank": 1,
"cut": "Card to remove",
"add": "Card to add instead",
"reasoning": "Why this is the single highest-impact swap",
"expectedImpact": "high|medium|low"
}}
],
"manaBaseAdvice": "Specific mana base improvement suggestion or null",
"rawTextExplanation": "Detailed paragraph explaining the analysis and reasoning"
"commanderDependency": {{
"score": 5,
"dependentCards": ["Card that is nearly dead without commander"],
"recoveryPlan": "Strategy if the commander gets removed or tucked repeatedly"
}},
"mulliganAnalysis": {{
"estimatedKeepRate": "Percentage estimate of keepable 7-card hands, e.g. ~70%",
"worstOffenders": ["Card most responsible for unkeepable hands"],
"recommendation": "What to change to improve opening hand quality"
}},
"manaCurveAnalysis": {{
"currentAssessment": "Detailed analysis of the current mana curve distribution and its implications",
"recommendations": "Specific suggestions for curve adjustments with reasoning",
"idealRange": "What the curve should look like for this deck's strategy"
}},
"roleBreakdown": {{
"ramp": "Assessment of ramp package - count, quality, and suggestions",
"draw": "Assessment of card draw package",
"removal": "Assessment of removal package",
"threats": "Assessment of win conditions and threats",
"protection": "Assessment of protection and interaction"
}},
"synergyAnalysis": [
"Description of a key synergy chain in the deck and how well it performed in simulation"
],
"heuristicHints": [
"Detailed strategic tip explaining the reasoning and data behind the suggestion"
],
"manaBaseAdvice": "Specific mana base improvement suggestion including land count, color fixing, and utility lands, or null if mana base is solid",
"rawTextExplanation": "3-5 paragraph in-depth analysis covering: (1) overall deck strategy assessment and how well the deck executes its game plan based on simulation data, (2) mana curve and resource analysis with specific numbers including avgTurnCast for key cards, (3) card synergy evaluation highlighting the strongest and weakest interactions using synergyScore data, (4) meta considerations and matchup-specific insights referencing perArchetypeWinRates, and (5) prioritized list of improvements ranked by expected impact on win rate",
"removalCoverage": {{
"creatures": "Assessment of creature removal",
"artifacts": "Assessment of artifact removal",
"enchantments": "Assessment of enchantment removal",
"planeswalkers": "Assessment of planeswalker removal",
"lands": "Assessment of land destruction",
"massRemoval": "Assessment of board wipes",
"gaps": ["Permanent types with zero or weak coverage"]
}},
"rampQuality": {{
"manaRocks": "2-mana rocks, signets, etc.",
"landFetch": "Cultivate, Kodama's Reach, etc.",
"manaDorks": "Llanowar Elves, Birds, etc.",
"costReducers": "Medallions, Helm of Awakening, etc.",
"fragility": "Overall fragility assessment",
"canReachFourByTurnThree": "yes/no with reasoning"
}},
"drawEngineProfile": {{
"burstDraw": ["One-time draw spells"],
"repeatableEngines": ["Sustained draw engines"],
"assessment": "Overall draw quality",
"sustainability": "Whether draw is sustainable or burst-only"
}},
"winConditions": [
{{
"name": "Name of win condition",
"cards": ["Key cards involved"],
"independence": "high|medium|low",
"description": "How this win con functions"
}}
],
"antiSynergyFlags": [
{{
"cards": ["Card A", "Card B"],
"conflict": "Description of conflict",
"severity": "high|medium|low"
}}
],
"podPresence": {{
"threatLevel": "early|medium|late|low",
"politicalTools": ["Pillowfort or group effect cards"],
"adversarialRating": "low|medium|high",
"recommendation": "How to manage threat perception in a pod"
}},
"tempoAssessment": {{
"peakTurn": 5,
"profile": "early|mid|late",
"survivalWindow": "Assessment of whether the deck survives to its power window",
"assessment": "Overall tempo evaluation"
}},
"metaMatchups": [
{{
"archetype": "fast-combo|stax|control|aggro|midrange",
"assessment": "How the deck fares against this archetype",
"keyThreats": ["Cards/strategies that threaten this deck"],
"keyAnswers": ["Cards in the deck that answer this archetype"],
"overallRating": "favored|even|unfavored"
}}
]
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════
# Quick Digest Prompt
# ══════════════════════════════════════════════════════════════
QUICK_DIGEST_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander deck coach.
Provide a FAST summary analysis of this deck. Be concise.
RULES:
- NEVER suggest cutting the commander ({commander}).
- Only suggest cards within the deck's color identity: {color_identity}.
{goals_section}
You MUST respond ONLY with valid JSON matching this exact structure:
{{
"summary": "3-sentence deck overview: what it does, main strength, main weakness.",
"upgradePriority": [
{{
"rank": 1,
"cut": "Card to remove",
"add": "Card to add instead",
"reasoning": "Why this is a high-impact swap",
"expectedImpact": "high|medium|low"
}}
],
"commanderDependency": {{
"score": 5,
"dependentCards": ["Card that is nearly dead without commander"],
"recoveryPlan": "Brief recovery strategy"
}}
}}
Do NOT include any text outside the JSON object. No markdown fences, no preamble."""
# ══════════════════════════════════════════════════════════════
# User Prompt Builder
@@ -82,124 +227,173 @@ Avg Game Length: {avg_game_length:.1f} turns
### Matchup Data
{matchup_section}
### Archetype Win Rates
{archetype_section}
### Top Performing Cards (by Impact Score)
{top_cards}
### Underperforming Cards (candidates for cuts)
{underperformer_section}
### RAG-Retrieved Similar Cards (from ChromaDB)
{rag_section}
### Candidate Replacement Cards (from embeddings search)
{candidate_section}
{combo_section}
Based on this simulation data, provide your coaching suggestions."""
def _format_goals_section(goals: Optional[CoachGoals]) -> str:
"""Format goals into system prompt rules."""
if not goals:
return "- Aim for a balanced, well-rounded deck."
lines = []
if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
if goals.targetPowerLevel <= 4:
lines.append("- Keep suggestions casual and fun-focused.")
elif goals.targetPowerLevel >= 8:
lines.append("- Prioritize efficiency and competitive staples.")
if goals.metaFocus:
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
if goals.budget:
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
"no-limit": "no budget restrictions"}
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
if goals.focusAreas:
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
"""Format goals into system prompt rules."""
if not goals:
return "- Aim for a balanced, well-rounded deck."
lines = []
if goals.targetPowerLevel is not None:
lines.append(f"- Target power level: {goals.targetPowerLevel}/10.")
if goals.targetPowerLevel <= 4:
lines.append("- Keep suggestions casual and fun-focused.")
elif goals.targetPowerLevel >= 8:
lines.append("- Prioritize efficiency and competitive staples.")
if goals.metaFocus:
lines.append(f"- Optimize for a {goals.metaFocus} strategy.")
if goals.budget:
budget_map = {"budget": "under $5 per card", "medium": "under $20 per card",
"no-limit": "no budget restrictions"}
lines.append(f"- Budget constraint: {budget_map.get(goals.budget, goals.budget)}.")
if goals.focusAreas:
lines.append(f"- Focus improvement on: {', '.join(goals.focusAreas)}.")
return "\n".join(lines) if lines else "- Aim for a balanced, well-rounded deck."
def _format_curve(buckets: List[int]) -> str:
"""Format mana curve buckets as a readable string."""
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
"""Format mana curve buckets as a readable string."""
labels = ["0", "1", "2", "3", "4", "5", "6", "7+"]
parts = [f"{labels[i]}={buckets[i]}" for i in range(min(len(buckets), 8)) if buckets[i] > 0]
return ", ".join(parts) if parts else "not available (analyze deck structure manually)"
def _format_top_cards(cards: List[CardPerformance], top_n: int = 10) -> str:
"""Format the best performing cards."""
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = []
for c in sorted_cards[:top_n]:
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}{tags}")
return "\n".join(lines) if lines else "No card performance data available."
"""Format the best performing cards with extended stats."""
sorted_cards = sorted(cards, key=lambda c: c.impactScore, reverse=True)
lines = []
for c in sorted_cards[:top_n]:
tags = f" [{', '.join(c.tags)}]" if c.tags else ""
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"castRate={c.castRate:.1%}, drawnRate={c.drawnRate:.1%}, "
f"synergy={c.synergyScore:.3f}{turn_str}{tags}"
)
return "\n".join(lines) if lines else "No card performance data available."
def _format_underperformers(cards: List[CardPerformance],
underperformer_names: List[str]) -> str:
"""Format underperforming cards with their stats."""
card_map = {c.name: c for c in cards}
lines = []
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
c = card_map.get(name)
if c:
lines.append(f"- {c.name}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}")
else:
lines.append(f"- {name}: (no detailed stats)")
return "\n".join(lines) if lines else "No clear underperformers identified."
underperformer_names: List[str]) -> str:
"""Format underperforming cards with full available stats."""
card_map = {c.name: c for c in cards}
lines = []
for name in underperformer_names[:MAX_UNDERPERFORMERS]:
c = card_map.get(name)
if c:
turn_str = f", avgTurnCast={c.avgTurnCast:.1f}" if c.avgTurnCast is not None else ""
lines.append(
f"- {c.name}: impact={c.impactScore:.3f}, "
f"deadCardRate={c.deadCardRate:.1%}, "
f"castRate={c.castRate:.1%}, "
f"clunkiness={c.clunkinessScore:.3f}, "
f"synergy={c.synergyScore:.3f}, "
f"keptInOpeningHand={c.keptInOpeningHandRate:.1%}"
f"{turn_str}"
)
else:
lines.append(f"- {name}: (no detailed stats)")
return "\n".join(lines) if lines else "No clear underperformers identified."
def _format_candidates(candidates: Dict[str, List[dict]]) -> str:
"""Format replacement candidates grouped by the card they'd replace."""
if not candidates:
return ("No embedding-based candidates available. "
"Use your MTG knowledge to suggest cards that fit the deck's "
"color identity, strategy, and missing functional roles. "
"Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility).")
"""Format replacement candidates grouped by the card they'd replace."""
if not candidates:
return (
"No embedding-based candidates available. "
"Use your MTG knowledge to suggest cards that fit the deck's "
"color identity, strategy, and missing functional roles. "
"Suggest at least 3 different cards across different roles "
"(ramp, draw, removal, threats, utility)."
)
lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':")
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
name = r.get("name", "Unknown")
types = r.get("types", "")
mv = r.get("mana_value", "?")
sim = r.get("similarity", 0)
text_preview = r.get("text", "")[:80]
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
if text_preview:
lines.append(f" Oracle: {text_preview}")
lines.append("\nChoose from these candidates when possible, but also suggest ")
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
return "\n".join(lines)
def _format_rag_cards(rag_cards: List[dict]) -> str:
"""Format RAG-retrieved cards from ChromaDB vector search."""
if not rag_cards:
return "No RAG results available."
lines = []
for underperformer, replacements in list(candidates.items())[:MAX_UNDERPERFORMERS]:
lines.append(f"\nReplacements for '{underperformer}':")
for r in replacements[:MAX_CANDIDATES_PER_UNDERPERFORMER]:
name = r.get("name", "Unknown")
types = r.get("types", "")
mv = r.get("mana_value", "?")
sim = r.get("similarity", 0)
text_preview = r.get("text", "")[:80]
lines.append(f" - {name} (MV:{mv}, {types}) [similarity: {sim:.3f}]")
if text_preview:
lines.append(f" Oracle: {text_preview}")
lines.append("\nChoose from these candidates when possible, but also suggest ")
lines.append("cards NOT in this list if they better address the deck's weaknesses.")
for card in rag_cards[:15]:
name = card.get("name", "Unknown")
types = card.get("types", "")
mv = card.get("mana_value", "?")
mc = card.get("mana_cost", "")
dist = card.get("distance", 0)
text = card.get("text", "")[:80]
lines.append(f"- {name} ({mc}, MV:{mv}, {types}) [distance: {dist:.3f}]")
if text:
lines.append(f" Oracle: {text}")
return "\n".join(lines)
def _format_matchups(report: DeckReport) -> str:
"""Format matchup data."""
if not report.matchups:
return "No matchup data available."
lines = []
for m in report.matchups:
lines.append(f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)")
return "\n".join(lines)
"""Format per-opponent matchup data."""
if not report.matchups:
return "No matchup data available."
lines = []
for m in report.matchups:
lines.append(
f"- vs {m.opponentDeck}: {m.winRate:.1%} "
f"({m.gamesPlayed} games)"
)
return "\n".join(lines)
def _format_archetypes(report: DeckReport) -> str:
"""Format per-archetype win rates from DeckMeta."""
rates = report.meta.perArchetypeWinRates
if not rates:
return "No archetype breakdown available."
lines = []
for archetype, wr in sorted(rates.items(), key=lambda x: x[1]):
lines.append(f"- vs {archetype}: {wr:.1%}")
return "\n".join(lines)
def _format_combos(report: DeckReport) -> str:
"""Format known combos section."""
if not report.knownCombos:
return ""
lines = ["### Known Combos"]
for combo in report.knownCombos:
cards = " + ".join(combo.cardNames)
lines.append(f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}")
return "\n".join(lines)
"""Format known combos section."""
if not report.knownCombos:
return ""
lines = ["### Known Combos"]
for combo in report.knownCombos:
cards = " + ".join(combo.cardNames)
lines.append(
f"- {cards}: winRate={combo.winRateWhenAssembled:.1%}, "
f"assemblyRate={combo.assemblyRate:.1%}"
)
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════
@@ -207,41 +401,53 @@ def _format_combos(report: DeckReport) -> str:
# ══════════════════════════════════════════════════════════════
def build_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
"""Build the system prompt with deck-specific rules."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return SYSTEM_PROMPT_TEMPLATE.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
goals: Optional[CoachGoals] = None) -> str:
"""Build the system prompt with deck-specific rules."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return SYSTEM_PROMPT_TEMPLATE.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
def build_quick_system_prompt(report: DeckReport,
goals: Optional[CoachGoals] = None) -> str:
"""Build the quick digest system prompt (~400-600 token target)."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "any"
return QUICK_DIGEST_SYSTEM_PROMPT.format(
commander=report.commander,
color_identity=color_str,
goals_section=_format_goals_section(goals),
)
def build_user_prompt(report: DeckReport,
candidates: Dict[str, List[dict]] = None) -> str:
"""Build the user prompt with full deck report data."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
if report.structure.cardTypeCounts else ["unknown"]
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
if report.structure.functionalCounts else ["unknown"]
return USER_PROMPT_TEMPLATE.format(
deck_name=report.deckId,
commander=report.commander,
color_identity=color_str,
games_simulated=report.meta.gamesSimulated,
win_rate=report.meta.overallWinRate,
avg_game_length=report.meta.avgGameLength,
land_count=report.structure.landCount,
curve_summary=_format_curve(report.structure.curveBuckets),
type_summary=", ".join(type_parts),
role_summary=", ".join(role_parts),
matchup_section=_format_matchups(report),
top_cards=_format_top_cards(report.cards),
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
combo_section=_format_combos(report),
)
candidates: Dict[str, List[dict]] = None,
rag_cards: List[dict] = None) -> str:
"""Build the user prompt with full deck report data."""
color_str = "/".join(report.colorIdentity) if report.colorIdentity else "unknown"
type_parts = [f"{k}={v}" for k, v in report.structure.cardTypeCounts.items()] \
if report.structure.cardTypeCounts else ["unknown"]
role_parts = [f"{k}={v}" for k, v in report.structure.functionalCounts.items()] \
if report.structure.functionalCounts else ["unknown"]
return USER_PROMPT_TEMPLATE.format(
deck_name=report.deckId,
commander=report.commander,
color_identity=color_str,
games_simulated=report.meta.gamesSimulated,
win_rate=report.meta.overallWinRate,
avg_game_length=report.meta.avgGameLength,
land_count=report.structure.landCount,
curve_summary=_format_curve(report.structure.curveBuckets),
type_summary=", ".join(type_parts),
role_summary=", ".join(role_parts),
matchup_section=_format_matchups(report),
archetype_section=_format_archetypes(report),
top_cards=_format_top_cards(report.cards),
underperformer_section=_format_underperformers(
report.cards, report.underperformers),
candidate_section=_format_candidates(candidates or {}),
rag_section=_format_rag_cards(rag_cards or []),
combo_section=_format_combos(report),
)
+11
View File
@@ -1,6 +1,9 @@
"""
Commander AI Lab — Deck Generation System Prompt
═════════════════════════════════════════════════
Phase 1 changes:
- Added explicit 99-card count enforcement rule
- Clarified that commander counts separately (not in the 99)
"""
SYSTEM_PROMPT = """You are an elite Magic: The Gathering Commander (EDH) deck builder with encyclopedic knowledge of every card ever printed, current metagame trends, and Commander Rules Committee bracket guidelines.
@@ -9,6 +12,8 @@ Your task is to build a complete, legal 100-card Commander deck.
RULES:
- Exactly 100 cards total: 1 commander + 99 other cards
- The commander IS included in the cards list but counts as card #100
- The other 99 cards must exactly match the SLOT BUDGET provided in the user prompt
- No more than 1 copy of any card except basic lands
- All cards must share the commander's color identity
- All cards must be legal in Commander format
@@ -16,6 +21,12 @@ RULES:
- Include approximately: 10 ramp sources, 10 card draw sources, 8-10 targeted removal, 2-3 board wipes, 3-5 protection pieces
- Build a cohesive strategy around the commander's abilities
CARD COUNT ENFORCEMENT (critical):
- The user prompt will specify a SLOT BUDGET with exact per-category counts
- You MUST fill every slot — returning fewer cards than the budget specifies will cause a validation error
- If you cannot find a perfect fit for a slot, use the best available card for that role
- Basic lands count toward the Land slot total
BRACKET RULES (Commander Rules Committee):
- Bracket 1 (Casual): Precon-level. No tutors, no fast mana, no infinite combos, no mass land destruction. Game Changers: 0
- Bracket 2 (Upgraded Casual): Moderate power. Limited tutors (1-2 total), no 2-card infinite combos, no fast mana except Sol Ring. Game Changers: 0-1
+43 -14
View File
@@ -1,11 +1,33 @@
"""
Commander AI Lab — Deck Generation User Prompt Builder
═══════════════════════════════════════════════════════
Constructs the user message for Perplexity deck generation
from a DeckGenV3Request.
Constructs the user message for LLM deck generation.
Phase 1 changes:
- slot_budget parameter injected into prompt so LLM targets exactly 99 cards
- Explicit card count requirement in prompt text
"""
from typing import List, Optional
from typing import Dict, List, Optional
def build_slot_budget_segment(slot_budget: Dict[str, int]) -> str:
"""
Build the slot budget prompt segment.
Informs the LLM of exact per-category card counts so it never undershoots.
"""
total = sum(slot_budget.values())
lines = [f"SLOT BUDGET (you must fill EXACTLY {total} cards, excluding the commander):"]
for cat, count in slot_budget.items():
lines.append(f" {cat}: {count}")
lines.append(f" TOTAL: {total}")
lines.append("")
lines.append(
f"CRITICAL: Return exactly {total} cards in the JSON 'cards' array "
"(not counting the commander). Every slot in the budget above must be filled. "
"Do not return more or fewer cards."
)
return "\n".join(lines)
def build_user_prompt(
@@ -18,6 +40,7 @@ def build_user_prompt(
budget_mode: str = "total",
omit_cards: List[str] = None,
collection_summary: str = "",
slot_budget: Optional[Dict[str, int]] = None,
) -> str:
"""
Build the user prompt for deck generation.
@@ -26,18 +49,19 @@ def build_user_prompt(
commander: Commander card name
commander_type: Type line from Scryfall
color_identity: List of color letters, e.g. ["U", "B"]
strategy: User-specified strategy focus (e.g. "zombie tokens")
strategy: User-specified strategy focus
target_bracket: Target bracket level 1-4
budget_usd: Optional budget constraint
budget_mode: "total" for total deck cost, "per_card" for per-card max
omit_cards: Cards to exclude
collection_summary: Pre-built collection summary text block
slot_budget: Dict of category -> card count (must sum to 99)
"""
ci_str = ", ".join(color_identity) if color_identity else "Unknown"
lines = [
f"Build a complete 100-card Commander deck.",
f"",
"Build a complete 100-card Commander deck.",
"",
f"COMMANDER: {commander}",
]
@@ -60,16 +84,21 @@ def build_user_prompt(
lines.append(f"OMIT LIST (do NOT include these cards): {', '.join(omit_cards)}")
if collection_summary:
lines.append(f"")
lines.append("")
lines.append(collection_summary)
lines.append(f"")
lines.append(f"STRONGLY PREFER cards from the collection when they fit the strategy.")
lines.append("")
lines.append("STRONGLY PREFER cards from the collection when they fit the strategy.")
else:
lines.append(f"")
lines.append(f"No collection data available — suggest the best cards regardless of ownership.")
lines.append("")
lines.append("No collection data available — suggest the best cards regardless of ownership.")
lines.append(f"")
lines.append(f"Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append(f"Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
# Inject slot budget so LLM knows exact target counts
if slot_budget:
lines.append("")
lines.append(build_slot_budget_segment(slot_budget))
lines.append("")
lines.append("Build the deck as a structured JSON response. Include the commander in the cards list.")
lines.append("Ensure exactly 100 total cards. Assign role_tags and bracket info accurately.")
return "\n".join(lines)
+1 -1
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Substitution Prompt Templates
═════════════════════════════════════════════════
Prompts for the Perplexity fallback substitution pass.
Prompts for the Claude Opus substitution pass.
"""
+2 -2
View File
@@ -38,7 +38,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
output_path.mkdir(parents=True, exist_ok=True)
# Load all batch results
batch_files = sorted(results_path.glob("batch-*.json"))
batch_files = sorted(results_path.glob("ml-decision-*.json"))
if not batch_files:
logger.info("No batch result files found in %s", results_dir)
return []
@@ -108,7 +108,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
# Also load existing batch results for comprehensive reports
results_dir = Path(batch_json_path).parent
all_batches = []
for bf in sorted(results_dir.glob("batch-*.json")):
for bf in sorted(results_dir.glob("ml-decision-*.json")):
try:
with open(bf, "r", encoding="utf-8") as f:
all_batches.append(json.load(f))
+2 -2
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Deck Generation JSON Schemas
═════════════════════════════════════════════════
Pydantic models and JSON Schema dicts for Perplexity
Pydantic models and JSON Schema dicts for Claude Opus
structured output deck generation.
"""
@@ -58,7 +58,7 @@ class GeneratedDeckList(BaseModel):
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for Perplexity response_format)
# JSON Schema dicts (for Claude Opus response_format)
# ══════════════════════════════════════════════════════════════
DECK_CARD_SCHEMA = {
+21 -5
View File
@@ -3,6 +3,10 @@ Commander AI Lab — Substitution Schemas
════════════════════════════════════════
Pydantic models and JSON Schema dicts for the
Smart Substitution Engine output.
Phase 1 changes:
- SubstitutionBatchResult gains padded_slots field
- DeckCardWithStatus gains original_card field (was missing)
"""
from typing import List, Optional
@@ -18,16 +22,17 @@ class CardAlternative(BaseModel):
name: str = Field(description="Exact card name")
similarity_score: float = Field(default=0.0, description="Cosine similarity score (0-1)")
reason: str = Field(default="", description="Why this card is a good substitute")
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'perplexity'")
source: str = Field(default="embedding", description="How it was found: 'embedding' or 'anthropic'")
role_overlap: List[str] = Field(default_factory=list, description="Shared functional roles")
cmc_delta: float = Field(default=0.0, description="CMC difference from original")
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
@@ -41,9 +46,15 @@ class DeckCardWithStatus(BaseModel):
owned_qty: int = 0
scryfall_id: str = ""
status: str = Field(default="owned", description="owned | substituted | missing")
original_card: Optional[str] = Field(default=None, description="Original card name if this is a substitution")
original_card: Optional[str] = Field(
default=None,
description="Original card name before substitution (None if not substituted)"
)
alternatives: List[CardAlternative] = Field(default_factory=list)
selected_substitute: Optional[str] = Field(default=None, description="User-selected substitute name")
selected_substitute: Optional[str] = Field(
default=None,
description="The substitute card name chosen for this slot"
)
class SubstitutionRequest(BaseModel):
@@ -61,11 +72,16 @@ class SubstitutionBatchResult(BaseModel):
owned_count: int = 0
substituted_count: int = 0
missing_count: int = 0
padded_slots: int = Field(
default=0,
description="Number of slots filled by the pad-to-99 circuit breaker. "
"Any value > 0 indicates a bug in the substitution pipeline."
)
cards: List[DeckCardWithStatus] = Field(default_factory=list)
# ══════════════════════════════════════════════════════════════
# JSON Schema dicts (for Perplexity fallback substitution)
# JSON Schema dicts (for LLM fallback substitution)
# ══════════════════════════════════════════════════════════════
SUBSTITUTION_RESPONSE_SCHEMA = {
+427 -118
View File
@@ -1,19 +1,28 @@
"""
Commander AI Lab — V3 Deck Generator Service
═════════════════════════════════════════════
Generates Commander decks using Perplexity Sonar
Generates Commander decks using Anthropic Claude
structured JSON output, then runs ownership check
and smart substitution.
Phase 1 changes:
- smart_substitute() non-destructive contract (never returns None)
- apply_substitutions() loop never silently drops cards
- _pad_to_99() circuit breaker before BuildResult validation
- Pre-validation count logging per category
- Startup SLOT_BUDGET assertion
"""
import json
import logging
import re
import sqlite3
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from ..clients.perplexity_client import PerplexityClient
from ..clients.anthropic_client import AnthropicClient
from ..schemas.deck_schema import DECK_LIST_SCHEMA, GeneratedDeckList
from ..schemas.substitution_schema import (
CardAlternative, DeckCardWithStatus, SubstitutionBatchResult,
@@ -26,35 +35,261 @@ from ..prompts.substitution_prompt import (
from ..config import (
DECK_GEN_MODEL, DECK_GEN_TEMPERATURE, DECK_GEN_MAX_TOKENS,
SUBSTITUTION_MIN_SIMILARITY, SUBSTITUTION_MAX_ALTERNATIVES,
SUBSTITUTION_MODEL, SUBSTITUTION_USE_PPLX_FALLBACK,
SUBSTITUTION_MODEL, SUBSTITUTION_USE_ANTHROPIC_FALLBACK,
)
logger = logging.getLogger("coach.deckgen")
# ══════════════════════════════════════════════════════════════
# Slot Budget — MUST sum to 99 (commander is the 100th card)
# ══════════════════════════════════════════════════════════════
SLOT_BUDGET: dict[str, int] = {
"Land": 36,
"Ramp": 10,
"Card Draw": 10,
"Removal": 8,
"Board Wipe": 3,
"Win Condition": 4,
"Protection": 4,
"Synergy": 20,
"Creature": 2,
"Utility": 2,
}
# ── Startup assertion — catches misconfigured budgets at boot time ──
_SLOT_BUDGET_TOTAL = sum(SLOT_BUDGET.values())
assert _SLOT_BUDGET_TOTAL == 99, (
f"SLOT_BUDGET sums to {_SLOT_BUDGET_TOTAL}, must equal 99. "
f"Current distribution: {SLOT_BUDGET}"
)
# ══════════════════════════════════════════════════════════════
# SubstitutionMethod — tracks HOW each card was resolved
# ══════════════════════════════════════════════════════════════
class SubstitutionMethod(str, Enum):
EXACT_MATCH = "exact_match" # Card found verbatim in collection
FUZZY_MATCH = "fuzzy_match" # Case/punctuation-normalised match
EMBEDDING_ANALOG = "embedding_analog" # Embedding similarity match
ANTHROPIC_FALLBACK = "anthropic_fallback" # LLM suggested replacement
ORIGINAL_KEPT = "original_kept" # Not in collection but Scryfall-valid
BASIC_LAND_FALLBACK = "basic_land_fallback" # Last resort — slot becomes a basic
@dataclass
class SubstitutionRecord:
"""Audit trail entry for a single card's resolution."""
original: str
resolved: str
method: SubstitutionMethod
in_collection: bool
# ══════════════════════════════════════════════════════════════
# Color → Basic Land mapping
# ══════════════════════════════════════════════════════════════
_COLOR_TO_BASIC: dict[str, str] = {
"W": "Plains",
"U": "Island",
"B": "Swamp",
"R": "Mountain",
"G": "Forest",
}
_BASIC_NAMES: set[str] = set(_COLOR_TO_BASIC.values()) | {"Wastes"}
def _get_basic_land(color_identity: List[str]) -> str:
"""Return the first appropriate basic land for the given color identity."""
for color in color_identity:
if color.upper() in _COLOR_TO_BASIC:
return _COLOR_TO_BASIC[color.upper()]
return "Wastes" # Colorless commander
# ══════════════════════════════════════════════════════════════
# Non-destructive Smart Substitution
# ══════════════════════════════════════════════════════════════
def smart_substitute(
card_name: str,
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> SubstitutionRecord:
"""
Non-destructive substitution — ALWAYS returns a valid card string.
Fallback chain:
1. Exact match in collection
2. Fuzzy match (case + punctuation normalised)
3. Embedding similarity match (if index available)
4. Keep original (Scryfall-valid, not in collection)
5. Basic land for the commander's colors (last resort — NEVER None)
"""
# 1. Exact match
if card_name in collection:
return SubstitutionRecord(card_name, card_name, SubstitutionMethod.EXACT_MATCH, True)
# 2. Fuzzy match — normalise case and common punctuation differences
normalised = card_name.lower().strip()
for owned in collection:
if owned.lower().strip() == normalised:
return SubstitutionRecord(card_name, owned, SubstitutionMethod.FUZZY_MATCH, True)
# 3. Embedding similarity (if index available)
if embedding_index and getattr(embedding_index, "loaded", False):
try:
matches = embedding_index.search_similar(
query_card=card_name,
color_filter=color_identity or None,
exclude_cards=[card_name],
top_n=5,
)
pool_lower = {n.lower() for n in collection}
for match in matches:
if match.name.lower() in pool_lower:
return SubstitutionRecord(
card_name, match.name, SubstitutionMethod.EMBEDDING_ANALOG, True
)
except Exception as e:
logger.warning("Embedding substitution failed for '%s': %s", card_name, e)
# 4. Keep original — valid card not in collection (proxy / non-collection build)
return SubstitutionRecord(
card_name, card_name, SubstitutionMethod.ORIGINAL_KEPT, False
)
def apply_substitutions(
card_list: List[DeckCardWithStatus],
collection: set[str],
color_identity: List[str],
embedding_index=None,
) -> Tuple[List[DeckCardWithStatus], List[SubstitutionRecord]]:
"""
Apply smart substitution to a card list.
Guarantees: output list is NEVER shorter than input list.
Logs a warning for every BASIC_LAND_FALLBACK.
"""
records: List[SubstitutionRecord] = []
result: List[DeckCardWithStatus] = []
for card in card_list:
rec = smart_substitute(card.name, collection, color_identity, embedding_index)
records.append(rec)
if rec.method == SubstitutionMethod.BASIC_LAND_FALLBACK:
logger.warning(
"[substitution] BASIC_LAND_FALLBACK for '%s' — slot filled with '%s'",
rec.original, rec.resolved,
)
# Update the card in-place — never drop it
updated = card.model_copy(update={
"name": rec.resolved,
"owned": rec.in_collection,
"status": "owned" if rec.in_collection else
("substituted" if rec.method != SubstitutionMethod.ORIGINAL_KEPT
else "missing"),
"original_card": card.name if rec.resolved != card.name else card.original_card,
})
result.append(updated)
fallback_count = sum(1 for r in records if r.method == SubstitutionMethod.BASIC_LAND_FALLBACK)
if fallback_count:
logger.error(
"[substitution] %d cards fell back to basic lands — investigate LLM output quality",
fallback_count,
)
return result, records
# ══════════════════════════════════════════════════════════════
# Pad-to-99 Circuit Breaker
# ══════════════════════════════════════════════════════════════
def _pad_to_99(
cards: List[DeckCardWithStatus],
color_identity: List[str],
target: int = 99,
) -> Tuple[List[DeckCardWithStatus], int]:
"""
Emergency safety pad — should NEVER fire if substitution contract holds.
If it does fire, the error log is your signal that a card is being dropped
somewhere earlier in the pipeline.
Returns (padded_cards, number_of_slots_padded).
"""
# Strip commander from count if present (commander is tracked separately)
non_commander = [c for c in cards if not getattr(c, "is_commander", False)]
current = sum(c.count for c in non_commander)
shortfall = target - current
if shortfall <= 0:
return cards, 0
logger.error(
"[pad_to_99] CIRCUIT BREAKER FIRED — deck is %d cards short after substitution. "
"Padding with basic lands. Check substitution pipeline for silent drops.",
shortfall,
)
basic = _get_basic_land(color_identity)
padding_card = DeckCardWithStatus(
name=basic,
count=shortfall,
category="Land",
role_tags=[],
reason="Emergency pad — basic land inserted by circuit breaker",
estimated_price_usd=0.10,
owned=True,
owned_qty=shortfall,
status="owned",
)
return cards + [padding_card], shortfall
def _log_pre_validation_counts(cards: List[DeckCardWithStatus]) -> None:
"""Log per-category card counts before Pydantic validation."""
by_cat: dict[str, int] = {}
for card in cards:
cat = card.category or "Uncategorized"
by_cat[cat] = by_cat.get(cat, 0) + card.count
total = sum(by_cat.values())
logger.info("[pre_validation] Category counts: %s", by_cat)
logger.info("[pre_validation] Total: %d/99 (delta: %d)", total, 99 - total)
# ══════════════════════════════════════════════════════════════
# DeckGeneratorV3
# ══════════════════════════════════════════════════════════════
class DeckGeneratorV3:
"""
V3 Deck Generator using Perplexity structured output.
V3 Deck Generator using Anthropic Claude structured output.
Pipeline:
1. Resolve commander via Scryfall
2. Build collection summary
3. Call Perplexity with structured JSON schema
3. Call Anthropic Claude with structured JSON schema
4. Validate card names via Scryfall
5. Check ownership against collection DB
6. Run Smart Substitution (embedding + Perplexity fallback)
7. Return finalized deck with substitution data
6. Run Smart Substitution (non-destructive)
7. _pad_to_99 circuit breaker
8. Pre-validation count logging
9. Return finalized deck — guaranteed 99 cards
"""
def __init__(self, pplx_client: PerplexityClient, db_conn_factory,
def __init__(self, anthropic_client: AnthropicClient, db_conn_factory,
embedding_index=None):
"""
Args:
pplx_client: Configured PerplexityClient instance
db_conn_factory: Callable that returns sqlite3.Connection
embedding_index: Optional MTGEmbeddingIndex for substitution
"""
self.pplx = pplx_client
self.anthropic = anthropic_client
self.db_conn_factory = db_conn_factory
self.embedding_index = embedding_index
@@ -77,9 +312,12 @@ class DeckGeneratorV3:
"mana_cost": data.get("mana_cost", ""),
"image_url": data.get("image_uris", {}).get("normal", ""),
"oracle_text": data.get("oracle_text", ""),
"scryfall_found": True,
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", commander_name, e)
# Non-canonical commander (silver-bordered, custom, etc.)
# Skip collection substitution for these — trust LLM output directly
return {
"name": commander_name,
"scryfall_id": "",
@@ -88,6 +326,7 @@ class DeckGeneratorV3:
"mana_cost": "",
"image_url": "",
"oracle_text": "",
"scryfall_found": False,
}
# ── Step 2: Build Collection Summary ─────────────────────
@@ -110,16 +349,10 @@ class DeckGeneratorV3:
if not rows:
return ""
# Filter by color identity
ci_set = set(color_identity) if color_identity else None
filtered = []
for row in rows:
name = row["name"]
type_line = row["type_line"] or ""
# Basic lands and colorless are always allowed
if ci_set:
# Simple color check from type_line and name
# For proper filtering we'd check mana cost but this is good enough for summary
card_colors = set()
oracle = row["oracle_text"] or ""
mana_cost = ""
@@ -137,7 +370,6 @@ class DeckGeneratorV3:
if not filtered:
return ""
# Group by card type
groups = {}
for row in filtered:
type_line = row["type_line"] or ""
@@ -159,14 +391,12 @@ class DeckGeneratorV3:
group = "Other"
groups.setdefault(group, []).append(row)
# Sort each group by popularity/price and build text
lines = [f"COLLECTION SUMMARY ({len(filtered)} cards in deck colors):"]
for group_name in ["Creatures", "Instants", "Sorceries", "Artifacts",
"Enchantments", "Planeswalkers", "Lands", "Other"]:
cards = groups.get(group_name, [])
if not cards:
continue
# Sort by price descending (higher value = likely better card)
cards.sort(key=lambda r: r["tcg_price"] or 0, reverse=True)
card_strs = []
for c in cards[:max_per_group]:
@@ -176,7 +406,7 @@ class DeckGeneratorV3:
return "\n".join(lines)
# ── Step 3: Generate Deck via Perplexity ─────────────────
# ── Step 3: Generate Deck via Anthropic Claude ─────────────────
def generate_deck(
self,
commander_name: str,
@@ -193,24 +423,33 @@ class DeckGeneratorV3:
Returns dict with:
- commander: dict with Scryfall data
- raw_deck: parsed GeneratedDeckList from Perplexity
- cards: list of DeckCardWithStatus (after ownership check)
- raw_deck: parsed GeneratedDeckList from Anthropic Claude
- cards: list of DeckCardWithStatus (after ownership check + substitution)
- stats: deck statistics
- substitution_log: list of SubstitutionRecord dicts
- padded_slots: int > 0 means circuit breaker fired
- tokens_used: {prompt, completion}
"""
# Step 1: Resolve commander
cmdr = self.resolve_commander(commander_name)
color_identity = cmdr["color_identity"]
logger.info("Commander: %s, CI: %s", cmdr["name"], color_identity)
scryfall_found = cmdr.get("scryfall_found", True)
logger.info("Commander: %s, CI: %s, Scryfall: %s",
cmdr["name"], color_identity, scryfall_found)
# Step 2: Collection summary
collection_block = ""
if use_collection:
if use_collection and scryfall_found:
collection_block = self.build_collection_summary(color_identity)
logger.info("Collection summary: %d chars",
len(collection_block) if collection_block else 0)
elif not scryfall_found:
logger.info(
"Non-canonical commander '%s' — skipping collection filter, "
"trusting LLM output directly.", cmdr["name"]
)
# Step 3: Build prompts
# Step 3: Build prompts (with slot budget injected)
user_prompt = build_user_prompt(
commander=cmdr["name"],
commander_type=cmdr["type_line"],
@@ -221,13 +460,14 @@ class DeckGeneratorV3:
budget_mode=budget_mode,
omit_cards=omit_cards,
collection_summary=collection_block,
slot_budget=SLOT_BUDGET,
)
# Step 4: Call Perplexity with structured output
# Step 4: Call LLM with structured output
use_model = model or DECK_GEN_MODEL
logger.info("Calling Perplexity (%s) for deck generation...", use_model)
logger.info("Calling LLM (%s) for deck generation...", use_model)
response = self.pplx.chat_structured(
response = self.anthropic.chat_structured(
system_prompt=SYSTEM_PROMPT,
user_prompt=user_prompt,
json_schema=DECK_LIST_SCHEMA,
@@ -239,24 +479,60 @@ class DeckGeneratorV3:
if not response.ok:
raise ValueError(
f"Perplexity returned invalid response: {response.content[:300]}"
f"LLM returned invalid response: {response.content[:300]}"
)
raw_deck = response.parsed_json
card_count = len(raw_deck.get("cards", []))
truncated = card_count < 90 # Commander decks should have ~100 cards
logger.info("Deck generated: %d cards, bracket %d%s",
card_count,
(raw_deck.get("bracket", {}).get("level", 0) if isinstance(raw_deck.get("bracket"), dict) else raw_deck.get("bracket", 0)),
" (TRUNCATED — response may have hit token limit)" if truncated else "")
truncated = card_count < 90
logger.info(
"Deck generated: %d cards, bracket %s%s",
card_count,
(raw_deck.get("bracket", {}).get("level", "?") if isinstance(raw_deck.get("bracket"), dict)
else raw_deck.get("bracket", "?")),
" (TRUNCATED — hit token limit)" if truncated else "",
)
# Step 5: Check ownership and build enriched card list
# Step 5: Check ownership
cards_with_status = self._check_ownership(raw_deck.get("cards", []))
# Step 5b: Fill basic lands to hit exactly 100 cards
# Step 5b: Fill basic lands to reach target_total=100
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 6: Compute stats
# Step 5c: Secondary deduplication
seen: dict[str, int] = {}
deduped: List[DeckCardWithStatus] = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
deduped[seen[key]].count += c.count
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
else:
seen[key] = len(deduped)
deduped.append(c)
cards_with_status = deduped
# Step 6: Run non-destructive smart substitution
# For non-canonical commanders (e.g. Sonic), skip collection filter
owned_pool: set[str] = set()
if use_collection and scryfall_found:
conn = self.db_conn_factory()
rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = {r["name"] for r in rows}
cards_with_status, substitution_log = apply_substitutions(
cards_with_status, owned_pool, color_identity, self.embedding_index
)
# Step 7: Pad-to-99 circuit breaker
cards_with_status, padded_slots = _pad_to_99(cards_with_status, color_identity)
# Step 8: Pre-validation count logging
_log_pre_validation_counts(cards_with_status)
# Step 9: Compute stats
stats = self._compute_stats(cards_with_status)
return {
@@ -270,6 +546,12 @@ class DeckGeneratorV3:
"archetype": raw_deck.get("archetype", ""),
"reasoning": raw_deck.get("reasoning", {}),
"estimated_total_usd": raw_deck.get("estimated_total_usd", 0),
"substitution_log": [
{"original": r.original, "resolved": r.resolved,
"method": r.method.value, "in_collection": r.in_collection}
for r in substitution_log
],
"padded_slots": padded_slots,
"tokens_used": {
"prompt": response.prompt_tokens,
"completion": response.completion_tokens,
@@ -283,17 +565,25 @@ class DeckGeneratorV3:
def _check_ownership(self, cards: List[dict]) -> List[DeckCardWithStatus]:
"""
Cross-reference generated cards against the collection DB.
Sets owned/scryfall_id/owned_qty and real price.
Deduplicates by name (case-insensitive).
"""
conn = self.db_conn_factory()
enriched = []
enriched: List[DeckCardWithStatus] = []
seen_names: dict[str, int] = {}
for card in cards:
name = card.get("name", "")
if not name:
continue
# Look up in collection
name_lower = name.lower()
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged", name)
continue
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
@@ -318,16 +608,14 @@ class DeckGeneratorV3:
scryfall_id=row["scryfall_id"] if row else "",
status="owned" if is_owned else "missing",
)
seen_names[name_lower] = len(enriched)
enriched.append(enriched_card)
return enriched
# ── Step 5b: Basic Land Fill ──────────────────────────────
_COLOR_TO_BASIC = {
'W': 'Plains', 'U': 'Island', 'B': 'Swamp',
'R': 'Mountain', 'G': 'Forest',
}
_BASIC_NAMES = set(_COLOR_TO_BASIC.values()) | {'Wastes'}
_COLOR_TO_BASIC = _COLOR_TO_BASIC
_BASIC_NAMES = _BASIC_NAMES
def _fill_basic_lands(
self,
@@ -349,7 +637,7 @@ class DeckGeneratorV3:
ci = [c.upper() for c in color_identity]
basic_names = [self._COLOR_TO_BASIC[c] for c in ci if c in self._COLOR_TO_BASIC]
if not basic_names:
basic_names = ['Wastes']
basic_names = ["Wastes"]
per_color = basics_needed // len(basic_names)
remainder = basics_needed % len(basic_names)
@@ -360,20 +648,20 @@ class DeckGeneratorV3:
non_basics.append(DeckCardWithStatus(
name=bname,
count=qty,
category='Land',
category="Land",
role_tags=[],
reason='Basic land for mana fixing',
reason="Basic land for mana fixing",
estimated_price_usd=0.10,
owned=True,
owned_qty=qty,
status='owned',
status="owned",
))
logger.info("Basic land fill: %d basics added across %d color(s)",
basics_needed, len(basic_names))
basics_needed, len(basic_names))
return non_basics
# ── Step 6: Smart Substitution ───────────────────────────
# ── Step 6: Smart Substitution (legacy wrapper for route compatibility) ──
def run_substitution(
self,
cards: List[DeckCardWithStatus],
@@ -383,15 +671,12 @@ class DeckGeneratorV3:
"""
Run the Smart Substitution Engine on missing cards.
Pipeline:
a) Identify missing cards
b) For each: try embedding similarity (free, fast)
c) For low-confidence matches: batch Perplexity fallback
d) Auto-select best substitute per card
Returns SubstitutionBatchResult with all cards updated.
Uses the non-destructive apply_substitutions() under the hood.
Falls back to Anthropic Claude for low-confidence matches,
then to basic land as last resort — NEVER drops a card.
"""
color_identity = commander.get("color_identity", [])
scryfall_found = commander.get("scryfall_found", True)
missing = [c for c in cards if not c.owned]
owned = [c for c in cards if c.owned]
@@ -406,47 +691,63 @@ class DeckGeneratorV3:
logger.info("Substitution: %d missing / %d owned", len(missing), len(owned))
# Build list of owned card names for substitution pool
# Build owned pool
conn = self.db_conn_factory()
owned_pool_rows = conn.execute(
"SELECT DISTINCT name FROM collection_entries WHERE quantity > 0"
).fetchall()
owned_pool = [r["name"] for r in owned_pool_rows]
owned_pool = {r["name"] for r in owned_pool_rows}
deck_names = {c.name.lower() for c in cards}
available_pool = {n for n in owned_pool if n.lower() not in deck_names}
# Exclude cards already in the deck
deck_names = set(c.name.lower() for c in cards)
available_pool = [n for n in owned_pool if n.lower() not in deck_names]
# a) Embedding pass
needs_pplx_fallback = []
# a) Embedding pass for missing cards
needs_anthropic_fallback: List[DeckCardWithStatus] = []
for card in missing:
embedding_alts = self._embedding_substitution(
card, color_identity, available_pool
card, color_identity, list(available_pool)
)
card.alternatives = embedding_alts
# Check if best match meets threshold
if embedding_alts and embedding_alts[0].similarity_score >= SUBSTITUTION_MIN_SIMILARITY:
card.selected_substitute = embedding_alts[0].name
card.status = "substituted"
# Remove selected from pool
if card.selected_substitute.lower() in [n.lower() for n in available_pool]:
available_pool = [n for n in available_pool
if n.lower() != card.selected_substitute.lower()]
card.owned = True
available_pool.discard(card.selected_substitute.lower())
else:
needs_pplx_fallback.append(card)
needs_anthropic_fallback.append(card)
# b) Perplexity fallback for low-confidence matches
if needs_pplx_fallback and SUBSTITUTION_USE_PPLX_FALLBACK:
self._pplx_substitution(
needs_pplx_fallback, available_pool,
# b) Anthropic Claude fallback
if needs_anthropic_fallback and SUBSTITUTION_USE_ANTHROPIC_FALLBACK:
self._anthropic_substitution(
needs_anthropic_fallback, list(available_pool),
commander, strategy
)
# Count results
# c) Any card STILL missing after both passes gets a basic land — never drop it
padded = 0
for card in missing:
if card.status == "missing":
basic = _get_basic_land(color_identity)
logger.warning(
"[substitution] '%s' unresolved after all passes — "
"replacing with basic land '%s'",
card.name, basic,
)
card.name = basic
card.category = "Land"
card.role_tags = []
card.owned = True
card.status = "substituted"
card.original_card = card.name
padded += 1
substituted = sum(1 for c in cards if c.status == "substituted")
still_missing = sum(1 for c in cards if c.status == "missing")
# Validate count hasn't changed
assert len(cards) == len(owned) + len(missing), \
f"[substitution] Card count changed: {len(owned) + len(missing)} -> {len(cards)}"
return SubstitutionBatchResult(
total_cards=len(cards),
owned_count=len(owned),
@@ -465,39 +766,37 @@ class DeckGeneratorV3:
if not self.embedding_index or not self.embedding_index.loaded:
return []
# Use the embeddings search to find similar cards
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3, # Over-fetch, then filter to owned
)
try:
matches = self.embedding_index.search_similar(
query_card=card.name,
color_filter=color_identity if color_identity else None,
type_filter=card.category if card.category else None,
exclude_cards=[card.name],
top_n=SUBSTITUTION_MAX_ALTERNATIVES * 3,
)
except Exception as e:
logger.warning("Embedding search failed for '%s': %s", card.name, e)
return []
# Filter to only cards in the available pool
pool_lower = set(n.lower() for n in available_pool)
pool_lower = {n.lower() for n in available_pool}
alternatives = []
for match in matches:
if match.name.lower() in pool_lower:
# Compute role overlap
match_roles = [] # Would need card's role_tags from DB
role_overlap = list(set(card.role_tags) & set(match_roles)) if match_roles else []
role_overlap = list(set(card.role_tags) & set(getattr(match, "role_tags", [])))
alternatives.append(CardAlternative(
name=match.name,
similarity_score=match.similarity,
reason=f"Similar to {card.name} (cosine sim: {match.similarity:.3f})",
source="embedding",
role_overlap=role_overlap,
cmc_delta=match.mana_value - (card.estimated_price_usd or 0),
cmc_delta=getattr(match, "mana_value", 0) - (card.estimated_price_usd or 0),
))
if len(alternatives) >= SUBSTITUTION_MAX_ALTERNATIVES:
break
return alternatives
def _pplx_substitution(
def _anthropic_substitution(
self,
missing_cards: List[DeckCardWithStatus],
available_pool: List[str],
@@ -505,13 +804,14 @@ class DeckGeneratorV3:
strategy: str,
):
"""
Batch Perplexity fallback for cards with low embedding confidence.
Mutates the cards in-place with substitution data.
Batch Anthropic Claude fallback for cards with low embedding confidence.
Mutates cards in-place. On failure, cards retain status='missing'
so the basic-land fallback in run_substitution() can catch them.
"""
if not available_pool:
logger.warning("[anthropic_substitution] No available pool — skipping")
return
# Build the request
missing_dicts = [
{
"name": c.name,
@@ -524,7 +824,7 @@ class DeckGeneratorV3:
user_prompt = build_substitution_prompt(
missing_cards=missing_dicts,
allowed_cards=available_pool[:200], # Limit to avoid token overflow
allowed_cards=available_pool[:200],
commander=commander.get("name", ""),
color_identity=commander.get("color_identity", []),
strategy=strategy,
@@ -532,7 +832,7 @@ class DeckGeneratorV3:
try:
from ..schemas.substitution_schema import SUBSTITUTION_RESPONSE_SCHEMA
response = self.pplx.chat_structured(
response = self.anthropic.chat_structured(
system_prompt=SUBSTITUTION_SYSTEM,
user_prompt=user_prompt,
json_schema=SUBSTITUTION_RESPONSE_SCHEMA,
@@ -543,13 +843,16 @@ class DeckGeneratorV3:
)
if not response.ok:
logger.warning("Perplexity substitution fallback failed — non-JSON response")
logger.warning(
"[anthropic_substitution] LLM returned non-JSON — "
"%d cards will fall through to basic land fallback",
len(missing_cards),
)
return
subs = response.parsed_json.get("substitutions", [])
logger.info("Perplexity suggested %d substitutions", len(subs))
logger.info("[anthropic_substitution] Received %d substitutions", len(subs))
# Apply substitutions
card_by_name = {c.name.lower(): c for c in missing_cards}
for sub in subs:
orig = sub.get("original", "").lower()
@@ -558,32 +861,38 @@ class DeckGeneratorV3:
if orig in card_by_name and substitute:
card = card_by_name[orig]
# Add as top alternative
pplx_alt = CardAlternative(
anthropic_alt = CardAlternative(
name=substitute,
similarity_score=0.80, # Nominal score for Perplexity suggestions
similarity_score=0.80,
reason=reason,
source="perplexity",
source="anthropic",
role_overlap=sub.get("role_overlap", []),
)
# Insert at front
card.alternatives.insert(0, pplx_alt)
card.alternatives.insert(0, anthropic_alt)
card.selected_substitute = substitute
card.status = "substituted"
card.owned = True
card.original_card = card.name
except Exception as e:
logger.error("Perplexity substitution fallback error: %s", e)
logger.error(
"[anthropic_substitution] Exception: %s — "
"%d cards will fall through to basic land fallback",
e, len(missing_cards),
)
# Do NOT re-raise — cards stay status='missing' and
# run_substitution()'s basic-land fallback will handle them
# ── Stats ────────────────────────────────────────────────
@staticmethod
def _compute_stats(cards: List[DeckCardWithStatus]) -> dict:
"""Compute deck statistics from the enriched card list."""
by_category = {}
by_status = {"owned": 0, "substituted": 0, "missing": 0}
by_category: dict[str, int] = {}
by_status: dict[str, int] = {"owned": 0, "substituted": 0, "missing": 0}
total_price = 0.0
owned_price = 0.0
missing_price = 0.0
role_counts = {}
role_counts: dict[str, int] = {}
for card in cards:
cat = card.category or "Other"
+129
View File
@@ -0,0 +1,129 @@
# Closed-Loop Iterative Distillation — Implementation Roadmap
> **Goal:** Create a feedback loop where the PPO self-play agent exports its game decisions
> as JSONL files, which the supervised training pipeline incorporates alongside Forge
> heuristic data. The supervised baseline gradually improves as the RL agent discovers
> better play patterns — the RL agent teaches the supervised model.
---
## Phase 1 — PPO Decision Exporter
**Goal:** PPO self-play writes decisions in the same JSONL format as Forge batch sims.
- [ ] Add a `DecisionExporter` class in `ml/training/self_play.py` that captures each
(state, action, reward, game_context) during PPO rollouts
- [ ] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl` using the exact same
schema as the Forge `DecisionSnapshot` files
- [ ] Include metadata fields to distinguish PPO-generated data:
- `"source": "ppo"`
- `"model_version"`
- `"reward"`
- `"episode_return"`
- [ ] Only export decisions from **winning** games initially (higher signal-to-noise)
**Files touched:** `ml/training/self_play.py`, new `ml/training/decision_exporter.py`
---
## Phase 2 — Dataset Builder Source Awareness
**Goal:** `dataset_builder.py` can ingest both Forge and PPO decision files with
configurable mixing.
- [ ] Update `build_dataset()` to scan for both `ml-decisions-sim-*.jsonl` (Forge) and
`ml-decisions-ppo-*.jsonl` (PPO) files
- [ ] Add a `source_weights` config param — e.g., `{"forge": 1.0, "ppo": 0.5}` — to
control sampling ratios so PPO data doesn't overwhelm the Forge baseline early on
- [ ] Add a `min_reward_threshold` filter for PPO data — only include decisions from
games above a certain reward score
- [ ] Tag each sample with its source for downstream analysis
**Files touched:** `ml/data/dataset_builder.py`, `ml/config/scope.py`
---
## Phase 3 — Quality Gate & Validation
**Goal:** Prevent bad PPO data from degrading the supervised model.
- [ ] Add a validation split in `dataset_builder.py` (hold out 10-15% of Forge data as
a fixed eval set)
- [ ] After each supervised training run, evaluate on the held-out Forge set — if
accuracy drops below a threshold compared to the previous model, reject the PPO
data batch
- [ ] Track win rate of the PPO agent — only export decisions once the PPO model exceeds
a configurable win rate (e.g., >30% in 4-player pods, above the 25% random baseline)
- [ ] Log data quality metrics: action distribution entropy, reward distribution of
exported games
**Files touched:** `ml/training/trainer.py`, `ml/eval/`, new `ml/data/quality_gate.py`
---
## Phase 4 — Distillation Loop Orchestrator
**Goal:** Automate the full cycle: supervised train -> PPO self-play -> export -> retrain.
- [ ] New `ml/training/distillation_loop.py` that orchestrates:
1. Supervised train on current dataset (Forge + any existing PPO data)
2. Run N episodes of PPO self-play using the new supervised model as starting policy
3. Export winning PPO decisions to JSONL
4. Quality gate check
5. If passed, merge into dataset and go to step 1
6. If failed, discard PPO batch, adjust hyperparameters, retry
- [ ] Add `max_iterations` and `convergence_check` (stop if win rate plateaus across
iterations)
- [ ] Each iteration is a "generation" — tag data and models with generation number
**Files touched:** new `ml/training/distillation_loop.py`, `ml/config/scope.py`
---
## Phase 5 — UI Integration & Monitoring
**Goal:** Expose the distillation loop in the Commander AI Lab web UI.
- [ ] Add a "Distillation" tab or section in `ui/ai-lab.js` showing:
- Current generation number
- Per-generation win rate trend chart
- Data composition pie chart (% Forge vs % PPO per generation)
- Start / stop / pause controls for the loop
- [ ] API endpoints in `routes/ml.py`:
- `POST /api/ml/distill/start`
- `GET /api/ml/distill/status`
- `POST /api/ml/distill/stop`
- [ ] History of each generation's metrics stored as JSON for the trends view
**Files touched:** `routes/ml.py`, `ui/ai-lab.js`, `ui/ai-lab.css`
---
## Phase 6 — Advanced: ELO Tracking & Model Tournament
**Goal:** Quantify improvement across generations.
- [ ] Pit each generation's model against previous generations and the Forge heuristic
in batch sims
- [ ] Compute ELO ratings per generation
- [ ] Store in `data/elo_history.json` and visualize in the UI as a line chart
- [ ] Use ELO delta as a convergence signal — stop the loop when ELO gain per generation
drops below a threshold
**Files touched:** new `ml/eval/elo_tracker.py`, `routes/ml.py`, `ui/ai-lab.js`
---
## Priority
| Phase | Priority | Description |
|-------|----------|-------------|
| 1 | **Critical** | PPO decision export — foundation for everything |
| 2 | **Critical** | Dataset builder ingests both sources |
| 3 | **Critical** | Quality gate prevents model regression |
| 4 | High | Automation of the full loop |
| 5 | Medium | UI visibility and controls |
| 6 | Low | ELO tracking and tournament system |
> **Phases 1-3** are the core — get those working and you have a functional closed loop.
> **Phase 4** automates it. **Phases 5-6** are polish and observability.
@@ -0,0 +1,93 @@
# Phase 1 — Self-Play Integration Checklist
> **Issue:** #65 — PPO Decision Exporter
> **Ref:** `docs/CLOSED-LOOP-DISTILLATION.md`
> **Status:** In Progress
---
## Completed
- [x] Create `ml/training/decision_exporter.py` with `DecisionExporter` class
- [x] `ExporterConfig` dataclass (output_dir, only_wins, model_version, min_episode_steps)
- [x] `build_decision_record()` — converts PPO step data into Forge-compatible JSONL format
- [x] `DecisionExporter.begin_episode()` / `record_step()` / `end_episode()` / `flush()` lifecycle
- [x] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl`
- [x] Include PPO metadata fields: `source`, `model_version`, `reward`, `episode_return`
- [x] Win-only filtering (only export decisions from winning games)
- [x] Short-episode gating (`min_episode_steps` threshold)
- [x] `_macro_action_to_forge_type()` mapping (MacroAction enum → Forge action type strings)
- [x] Export stats tracking (`stats` property)
---
## Remaining — Integrate into `ml/training/self_play.py`
### Import
- [ ] Add import: `from ml.training.decision_exporter import DecisionExporter`
### Modify `run_self_play_episode()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] After each `trajectory.append(...)` block (3 locations), add:
```python
if exporter is not None:
exporter.record_step(
game_state_snapshot=prev_snapshot,
action_idx=action_idx,
reward=reward,
)
```
### Modify `collect_rollouts()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] Before each episode, call:
```python
if exporter is not None:
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
```
- [ ] Pass `exporter=exporter` to `run_self_play_episode()`
- [ ] After outcome tracking, call:
```python
if exporter is not None:
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
ep_return = sum(s["reward"] for s in trajectory)
exporter.end_episode(won=won, episode_return=ep_return)
```
### Commit
- [ ] `git add ml/training/self_play.py`
- [ ] `git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"`
- [ ] `git push origin main`
---
## Integration Pattern
```
collect_rollouts(exporter=exporter)
└─ for each episode:
├─ exporter.begin_episode(seat, playstyle)
├─ run_self_play_episode(..., exporter=exporter)
│ └─ for each step:
│ ├─ trajectory.append(step_data)
│ └─ exporter.record_step(snapshot, action, reward)
└─ exporter.end_episode(won, episode_return)
# Caller does: exporter.flush()
```
---
## Terminal Commands
```bash
cd commander-ai-lab
git pull origin main
# Make edits to ml/training/self_play.py per checklist above
git add ml/training/self_play.py
git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"
git push origin main
```
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-73
View File
@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>commander-ai-lab-ui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
-35
View File
@@ -1,35 +0,0 @@
{
"name": "commander-ai-lab-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
-27
View File
@@ -1,27 +0,0 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { PageLayout } from './components/layout/PageLayout'
import { BatchSimPage } from './pages/BatchSimPage'
import { CollectionPage } from './pages/CollectionPage'
import { DecksPage } from './pages/DecksPage'
import { AutoGenPage } from './pages/AutoGenPage'
import { SimulatorPage } from './pages/SimulatorPage'
import { CoachPage } from './pages/CoachPage'
import { TrainingPage } from './pages/TrainingPage'
export default function App() {
return (
<BrowserRouter>
<PageLayout>
<Routes>
<Route path="/" element={<BatchSimPage />} />
<Route path="/collection" element={<CollectionPage />} />
<Route path="/decks" element={<DecksPage />} />
<Route path="/autogen" element={<AutoGenPage />} />
<Route path="/simulator" element={<SimulatorPage />} />
<Route path="/coach" element={<CoachPage />} />
<Route path="/training" element={<TrainingPage />} />
</Routes>
</PageLayout>
</BrowserRouter>
)
}
@@ -1,82 +0,0 @@
// ═══════════════════════════════════════════════════════════
// Base API client — all fetch calls go through here
// ═══════════════════════════════════════════════════════════
const BASE = '' // same origin; Vite proxy handles /api in dev
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text()
let message = `HTTP ${res.status}`
try {
const json = JSON.parse(body)
message = json.detail || json.error || message
} catch {
if (body) message = body
}
throw new ApiError(res.status, message)
}
// Handle 204 No Content
if (res.status === 204) return undefined as T
return res.json()
}
export async function get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const url = new URL(BASE + path, window.location.origin)
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
})
}
const res = await fetch(url.toString())
return handleResponse<T>(res)
}
export async function post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
return handleResponse<T>(res)
}
export async function postForm<T>(path: string, formData: FormData): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
body: formData,
})
return handleResponse<T>(res)
}
export async function put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function patch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function del<T>(path: string): Promise<T> {
const res = await fetch(BASE + path, { method: 'DELETE' })
return handleResponse<T>(res)
}
@@ -1,131 +0,0 @@
import { get, post } from './client'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachChatResponse, CoachApplyResult, CoachCardLikeResult
} from '../types'
export async function getCoachStatus() {
return get<CoachStatus>('/api/coach/status')
}
export async function getCoachDecks() {
return get<CoachDeck[]>('/api/coach/decks')
}
export async function getCoachDeckReport(deckId: string) {
return get<Record<string, unknown>>(`/api/coach/decks/${deckId}/report`)
}
export async function runCoachSession(deckId: string, goals?: Record<string, unknown>) {
return post<CoachSession>(`/api/coach/decks/${deckId}`, goals ? { goals } : undefined)
}
export async function getCoachSessions(deckId?: string) {
const params: Record<string, string> = {}
if (deckId) params.deck_id = deckId
return get<{ sessions: CoachSessionSummary[] }>('/api/coach/sessions', params)
}
export async function getCoachSession(sessionId: string) {
return get<CoachSession>(`/api/coach/sessions/${sessionId}`)
}
export async function downloadEmbeddings() {
return post<{ success: boolean; cards: number }>('/api/coach/embeddings/download')
}
export async function coachChat(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>
) {
return post<CoachChatResponse>('/api/coach/chat', {
deck_id: deckId,
messages,
goals,
stream: false,
})
}
export async function coachChatStream(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>,
onChunk?: (text: string) => void,
signal?: AbortSignal
): Promise<string> {
const res = await fetch('/api/coach/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deck_id: deckId,
messages,
goals,
stream: true,
}),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(body || `HTTP ${res.status}`)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let fullText = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') break
try {
const parsed = JSON.parse(data)
if (parsed.content) {
fullText += parsed.content
onChunk?.(fullText)
}
} catch { /* skip bad chunks */ }
}
}
}
return fullText
}
export async function applyCoachSuggestions(
sessionId: string,
deckId: number,
acceptedCuts: string[],
acceptedAdds: string[]
) {
return post<CoachApplyResult>('/api/coach/apply', {
session_id: sessionId,
deck_id: deckId,
accepted_cuts: acceptedCuts,
accepted_adds: acceptedAdds,
})
}
export async function searchCardsLike(card: string, colors?: string, topN = 10) {
const params: Record<string, string | number> = { card, top_n: topN }
if (colors) params.colors = colors
return get<{ query: string; results: CoachCardLikeResult[] }>('/api/coach/cards-like', params)
}
export async function generateReport(deckId?: number) {
return post<{ status: string; decksUpdated: string[]; count: number }>('/api/coach/reports/generate',
deckId ? { deck_id: deckId } : undefined
)
}
@@ -1,86 +0,0 @@
import { get, post, patch, postForm } from './client'
import type { CollectionCard, CollectionFilters, SetInfo, ScanResult } from '../types'
export async function searchCollection(filters: CollectionFilters) {
// Map frontend snake_case filter names to backend camelCase param names
const params: Record<string, string | number | boolean> = {}
if (filters.q) params.q = filters.q
if (filters.page) params.page = filters.page
if (filters.page_size) params.pageSize = filters.page_size
if (filters.sort_by) params.sortField = filters.sort_by
if (filters.sort_dir) params.sortDir = filters.sort_dir
if (filters.color) params.colors = filters.color
if (filters.type_line) params.types = filters.type_line
if (filters.rarity) params.rarity = filters.rarity
if (filters.set_code) params.setCode = filters.set_code
if (filters.cmc_min !== undefined) params.cmcMin = filters.cmc_min
if (filters.cmc_max !== undefined) params.cmcMax = filters.cmc_max
if (filters.price_min !== undefined) params.priceMin = filters.price_min
if (filters.price_max !== undefined) params.priceMax = filters.price_max
if (filters.owned_only !== undefined) params.owned_only = filters.owned_only
const res = await get<{ items?: CollectionCard[]; cards?: CollectionCard[]; total: number; page: number; pageSize?: number; page_size?: number }>(
'/api/collection', params
)
return {
cards: res.items || res.cards || [],
total: res.total || 0,
page: res.page || 1,
page_size: res.pageSize || res.page_size || 48,
}
}
export async function getCard(cardId: number) {
return get<CollectionCard>(`/api/collection/${cardId}`)
}
export async function updateCard(cardId: number, updates: Partial<CollectionCard>) {
return patch<CollectionCard>(`/api/collection/${cardId}`, updates)
}
export async function getSets() {
const res = await get<SetInfo[] | { sets: SetInfo[] }>('/api/collection/sets')
return Array.isArray(res) ? res : (res as { sets: SetInfo[] }).sets || []
}
export async function getKeywords() {
const res = await get<string[] | { keywords: string[] }>('/api/collection/keywords')
return Array.isArray(res) ? res : (res as { keywords: string[] }).keywords || []
}
export async function exportCollection(format: string = 'csv') {
const res = await fetch(`/api/collection/export?format=${format}`)
return res.blob()
}
export async function importCollection(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<{ imported: number; errors: string[] }>('/api/collection/import', fd)
}
export async function scanCard(file: File) {
const fd = new FormData()
fd.append('image', file)
return postForm<ScanResult>('/api/collection/scan', fd)
}
export async function addScanResult(match: { name: string; set_code: string; scryfall_id: string; quantity?: number }) {
return post<{ success: boolean; card_id: number }>('/api/collection/scan/add', match)
}
export async function reEnrichCards() {
return post<{ updated: number }>('/api/collection/re-enrich')
}
export async function autoClassify(cardId: number) {
return post<{ classifications: string[] }>(`/api/collection/auto-classify`, { card_id: cardId })
}
export async function autoClassifyAll() {
return post<{ processed: number }>('/api/collection/auto-classify-all')
}
export async function getEdhrecData(cardId: number) {
return get<Record<string, unknown>>(`/api/collection/${cardId}/edhrec`)
}
@@ -1,22 +0,0 @@
import { get, post } from './client'
import type { DeckGenV3Request, DeckGenV3Status, DeckGenV3Result, CommanderSearchResult } from '../types'
export async function getV3Status() {
return get<DeckGenV3Status>('/api/deck/v3/status')
}
export async function searchCommanders(q: string) {
return get<CommanderSearchResult[]>('/api/deck-generator/commander-search', { q })
}
export async function generateDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result>('/api/deck/v3/generate', req)
}
export async function commitDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result & { deck_id: number; dck_path: string }>('/api/deck/v3/commit', req)
}
export async function exportDeckV3(format: 'csv' | 'dck' | 'moxfield' | 'shopping', req: DeckGenV3Request) {
return post<Record<string, unknown>>(`/api/deck/v3/export/${format}`, req)
}
@@ -1,130 +0,0 @@
import { get, post, put, del, patch } from './client'
import type { Deck, DeckCard, DeckAnalysis, EdhRecsResponse, CollectionRecsResponse, PplxStatus } from '../types'
export async function listDecks() {
const res = await get<{ decks: Record<string, unknown>[] } | Record<string, unknown>[]>('/api/decks')
const raw = Array.isArray(res) ? res : (res as { decks: Record<string, unknown>[] }).decks || []
return raw.map(d => ({
...d,
commander: d.commander || d.commander_name || '',
card_count: d.card_count ?? d.total_cards ?? 0,
total_price: d.total_price ?? 0,
color_identity: d.color_identity || [],
created_date: d.created_date || d.created_at || '',
updated_date: d.updated_date || d.updated_at || '',
})) as Deck[]
}
export async function getDeck(deckId: number) {
return get<Deck>(`/api/decks/${deckId}`)
}
export async function createDeck(deck: { name: string; commander?: string; format?: string }) {
return post<Deck>('/api/decks', deck)
}
export async function updateDeck(deckId: number, updates: Partial<Deck>) {
return put<Deck>(`/api/decks/${deckId}`, updates)
}
export async function deleteDeck(deckId: number) {
return del<void>(`/api/decks/${deckId}`)
}
export async function deleteAllDecks() {
return del<void>('/api/decks')
}
export async function getDeckCards(deckId: number) {
const res = await get<{ cards: Record<string, unknown>[]; total: number } | Record<string, unknown>[]>(`/api/decks/${deckId}/cards`)
const raw = Array.isArray(res) ? res : (res as { cards: Record<string, unknown>[] }).cards || []
return raw.map(c => ({
...c,
name: c.name || c.card_name || '',
category: c.category || c.role_tag || '',
})) as DeckCard[]
}
export async function addCardToDeck(deckId: number, card: { name: string; scryfall_id?: string; quantity?: number; category?: string }) {
return post<DeckCard>(`/api/decks/${deckId}/cards`, card)
}
export async function removeCardFromDeck(deckId: number, cardId: number) {
return del<void>(`/api/decks/${deckId}/cards/${cardId}`)
}
export async function updateDeckCard(deckId: number, cardId: number, updates: Partial<DeckCard>) {
return patch<DeckCard>(`/api/decks/${deckId}/cards/${cardId}`, updates)
}
export async function getDeckAnalysis(deckId: number) {
const raw = await get<Record<string, unknown>>(`/api/decks/${deckId}/analysis`)
// Backend returns: counts_by_type, targets, deltas, mana_curve, color_pips, total_cards, roles
// Frontend DeckAnalysis expects: card_count, land_count, creature_count, noncreature_count, avg_cmc, mana_curve, color_distribution, type_distribution, total_price, owned_count, missing_count
const counts = (raw.counts_by_type || {}) as Record<string, number>
const totalCards = (raw.total_cards as number) || 0
const landCount = counts['Land'] || 0
const creatureCount = counts['Creature'] || 0
const manaCurve = (raw.mana_curve || {}) as Record<string | number, number>
const colorPips = (raw.color_pips || {}) as Record<string, number>
// Compute avg CMC from mana curve
let totalMana = 0, nonLandCards = 0
for (const [cmc, count] of Object.entries(manaCurve)) {
const cmcNum = cmc === '6+' ? 6 : Number(cmc)
totalMana += cmcNum * count
nonLandCards += count
}
return {
card_count: totalCards,
land_count: landCount,
creature_count: creatureCount,
noncreature_count: totalCards - landCount - creatureCount,
avg_cmc: nonLandCards > 0 ? totalMana / nonLandCards : 0,
mana_curve: manaCurve as Record<number, number>,
color_distribution: colorPips,
type_distribution: counts,
total_price: 0,
owned_count: totalCards,
missing_count: 0,
} as DeckAnalysis
}
export async function getCollectionRecs(deckId: number) {
return get<CollectionRecsResponse>(`/api/decks/${deckId}/recommended-from-collection`)
}
export async function getEdhRecs(deckId: number, onlyOwned = false, maxResults = 30) {
return get<EdhRecsResponse>(`/api/decks/${deckId}/edh-recs?only_owned=${onlyOwned}&max_results=${maxResults}`)
}
export async function bulkAddCards(deckId: number, cards: { name: string; quantity?: number }[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add`, { cards })
}
export async function bulkAddRecommended(deckId: number, cards: string[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add-recommended`, { card_names: cards })
}
export async function exportDeckToSim(deckId: number) {
return post<{ path: string }>(`/api/decks/${deckId}/export-to-sim`)
}
export async function importDeckToDeck(deckId: number, data: { text?: string; url?: string }) {
return post<{ imported: number }>(`/api/decks/${deckId}/import`, data)
}
export async function importNewDeck(data: { text?: string; url?: string; name?: string }) {
return post<Deck>('/api/decks/import-new', data)
}
export async function getPplxStatus() {
return get<PplxStatus>('/api/pplx/status')
}
export async function deckResearch(payload: { commander: string; strategy?: string }) {
return post<{ research: string }>('/api/deck-research', payload)
}
export async function deckGenerate(payload: { commander: string; strategy?: string; bracket?: number }) {
return post<{ cards: Array<{ name: string; quantity: number; category: string }> }>('/api/deck-generate', payload)
}
@@ -1,7 +0,0 @@
export * as collectionApi from './collection'
export * as decksApi from './decks'
export * as deckgenApi from './deckgen'
export * as labApi from './lab'
export * as simApi from './sim'
export * as coachApi from './coach'
export * as trainingApi from './training'
@@ -1,65 +0,0 @@
import { get, post } from './client'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
export async function getLabDecks() {
const res = await get<{ decks: LabDeck[] } | LabDeck[]>('/api/lab/decks')
return Array.isArray(res) ? res : (res as { decks: LabDeck[] }).decks || []
}
export async function startBatchSim(config: { decks: string[]; games: number; threads?: number; use_deepseek?: boolean; deepseek_deck?: string }) {
return post<{ run_id: string }>('/api/lab/start', config)
}
export async function startDeepSeekSim(config: { decks: string[]; games: number; deepseek_deck: string }) {
return post<{ run_id: string }>('/api/lab/start-deepseek', config)
}
export async function getLabStatus() {
return get<LabStatus>('/api/lab/status')
}
export async function getLabResult() {
return get<LabResult>('/api/lab/result')
}
export async function getLabHistory() {
const res = await get<{ results: LabHistoryEntry[] } | LabHistoryEntry[]>('/api/lab/history')
return Array.isArray(res) ? res : (res as { results: LabHistoryEntry[] }).results || []
}
export async function getPrecons() {
const res = await get<{ precons: PreconDeck[] } | PreconDeck[]>('/api/lab/precons')
return Array.isArray(res) ? res : (res as { precons: PreconDeck[] }).precons || []
}
export async function installPrecon(name: string) {
return post<{ installed: boolean }>('/api/lab/precons/install', { name })
}
export async function installPreconBatch(names: string[]) {
return post<{ installed: number }>('/api/lab/precons/install-batch', { names })
}
export async function getMetaCommanders() {
return get<Array<{ name: string; color_identity: string[] }>>('/api/lab/meta/commanders')
}
export async function searchMetaCommanders(q: string) {
return get<Array<{ name: string; color_identity: string[]; source: string }>>('/api/lab/meta/search', { q })
}
export async function fetchMetaDeck(commander: string) {
return post<{ deck_name: string; cards: Array<{ name: string; quantity: number }> }>('/api/lab/meta/fetch', { commander })
}
export async function importDeckFromUrl(url: string) {
return post<{ deck_name: string }>('/api/lab/import/url', { url })
}
export async function importDeckFromText(text: string, name?: string) {
return post<{ deck_name: string }>('/api/lab/import/text', { text, name })
}
export async function getLabLog() {
return get<{ log: string }>('/api/lab/log')
}
@@ -1,38 +0,0 @@
import { get, post } from './client'
import type { SimStatus, SimResult, DeepSeekStatus } from '../types'
export async function runSim(config: { deck_a: string; deck_b: string; games: number }) {
return post<{ sim_id: string }>('/api/sim/run', config)
}
export async function runSimFromDeck(deckId: number, config: { opponent?: string; games?: number }) {
return post<{ sim_id: string }>('/api/sim/run-from-deck', { deck_id: deckId, ...config })
}
export async function runDeepSeekSim(config: { deck_a: string; deck_b: string; games: number; deepseek_deck: string }) {
return post<{ sim_id: string }>('/api/sim/run-deepseek', config)
}
export async function getSimStatus(simId: string) {
return get<SimStatus>('/api/sim/status', { simId })
}
export async function getSimResult(simId: string) {
return get<SimResult>('/api/sim/result', { simId })
}
export async function connectDeepSeek() {
return post<DeepSeekStatus>('/api/deepseek/connect')
}
export async function getDeepSeekStatus() {
return get<DeepSeekStatus>('/api/deepseek/status')
}
export async function configureDeepSeek(config: { model?: string; base_url?: string }) {
return post<DeepSeekStatus>('/api/deepseek/configure', config)
}
export async function getDeepSeekLogs() {
return get<Array<{ filename: string; size: number; modified: string }>>('/api/deepseek/logs')
}
@@ -1,92 +0,0 @@
import { get, post } from './client'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, TournamentResults, MLModelInfo
} from '../types'
// ── ML Status & Toggle ──────────────────────────────────────
export async function getMLStatus() {
return get<MLStatus>('/api/ml/status')
}
export async function toggleML(enable = true) {
return post<{ ml_logging_enabled: boolean; message: string }>(`/api/ml/toggle?enable=${enable}`)
}
// ── Data & Model Info ───────────────────────────────────────
export async function getDataStatus() {
return get<MLDataStatus>('/api/ml/data/status')
}
export async function getModelInfo() {
return get<MLModelInfo>('/api/ml/model')
}
export async function reloadModel(checkpoint?: string) {
return post<{ success: boolean; status: MLModelInfo }>('/api/ml/reload', checkpoint ? { checkpoint } : undefined)
}
// ── Supervised Training ─────────────────────────────────────
export async function startTraining(config: {
epochs?: number
lr?: number
batchSize?: number
patience?: number
rebuildDataset?: boolean
}) {
return post<{ status: string; config: Record<string, unknown> }>('/api/ml/train', config)
}
export async function getTrainStatus() {
return get<MLTrainStatus>('/api/ml/train/status')
}
// ── PPO Training ────────────────────────────────────────────
export async function startPPOTraining(config: {
iterations?: number
episodesPerIter?: number
ppoEpochs?: number
batchSize?: number
lr?: number
clipEpsilon?: number
entropyCoeff?: number
opponent?: string
playstyle?: string
loadSupervised?: string
}) {
return post<{ status: string; iterations: number }>('/api/ml/train/ppo', config)
}
export async function getPPOStatus() {
return get<PPOTrainStatus>('/api/ml/train/ppo/status')
}
// ── Tournament ──────────────────────────────────────────────
export async function startTournament(config: {
episodes?: number
playstyle?: string
}) {
return post<{ status: string; episodes: number }>('/api/ml/tournament', config)
}
export async function getTournamentStatus() {
return get<TournamentStatus>('/api/ml/tournament/status')
}
export async function getTournamentResults() {
return get<TournamentResults>('/api/ml/tournament/results')
}
// ── Decision Files ──────────────────────────────────────────
export async function getDecisionFile(filename: string, limit = 100, offset = 0) {
return get<{ file: string; offset: number; limit: number; count: number; decisions: Record<string, unknown>[] }>(
`/api/ml/decisions/${filename}?limit=${limit}&offset=${offset}`
)
}
// ── Prediction ──────────────────────────────────────────────
export async function predict(state: Record<string, unknown>) {
return post<{ action: string; action_index: number; confidence: number; probabilities: Record<string, number>; inference_ms: number }>(
'/api/ml/predict', state
)
}
@@ -1,49 +0,0 @@
import { useState } from 'react'
interface CardImageProps {
src: string
alt: string
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
onClick?: () => void
}
const SIZES = {
sm: 'w-16 h-[22px]', // tiny inline
md: 'w-[146px] h-[204px]', // grid thumbnail
lg: 'w-[244px] h-[340px]', // detail view
xl: 'w-[336px] h-[468px]', // full size
}
export function CardImage({ src, alt, size = 'md', className = '', onClick }: CardImageProps) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
// Build Scryfall URL if needed — ensure we get the right size
const imgUrl = src || ''
return (
<div
className={`relative overflow-hidden rounded-lg bg-bg-tertiary ${SIZES[size]} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-accent-blue/50 transition-all' : ''} ${className}`}
onClick={onClick}
>
{!error ? (
<img
src={imgUrl}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
loading="lazy"
/>
) : (
<div className="flex items-center justify-center w-full h-full text-text-tertiary text-xs text-center p-2">
{alt}
</div>
)}
{!loaded && !error && (
<div className="absolute inset-0 bg-bg-tertiary animate-pulse" />
)}
</div>
)
}
@@ -1,19 +0,0 @@
import type { LucideIcon } from 'lucide-react'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: React.ReactNode
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Icon className="w-12 h-12 text-text-tertiary mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-semibold text-text-primary mb-1">{title}</h3>
{description && <p className="text-sm text-text-secondary max-w-md mb-4">{description}</p>}
{action}
</div>
)
}
@@ -1,69 +0,0 @@
// Parse MTG mana cost strings like "{2}{W}{U}" into colored symbols
const MANA_COLORS: Record<string, string> = {
W: 'bg-mana-white text-black',
U: 'bg-mana-blue text-white',
B: 'bg-mana-black text-white',
R: 'bg-mana-red text-white',
G: 'bg-mana-green text-white',
C: 'bg-mana-colorless text-white',
}
interface ManaSymbolsProps {
cost: string
size?: 'sm' | 'md'
}
export function ManaSymbols({ cost, size = 'sm' }: ManaSymbolsProps) {
if (!cost) return null
const symbols = cost.match(/\{([^}]+)\}/g)
if (!symbols) return <span className="text-text-tertiary">{cost}</span>
const sz = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'
return (
<span className="inline-flex items-center gap-0.5">
{symbols.map((s, i) => {
const val = s.replace(/[{}]/g, '')
const colorClass = MANA_COLORS[val] || 'bg-mana-colorless text-white'
return (
<span
key={i}
className={`${sz} inline-flex items-center justify-center rounded-full font-bold ${colorClass}`}
>
{val}
</span>
)
})}
</span>
)
}
// Color identity dots
interface ColorDotsProps {
colors: string[]
size?: 'sm' | 'md'
}
export function ColorDots({ colors, size = 'sm' }: ColorDotsProps) {
if (!colors || colors.length === 0) return <span className="text-text-tertiary text-xs">Colorless</span>
const sz = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
const DOT_COLORS: Record<string, string> = {
W: 'bg-mana-white',
U: 'bg-mana-blue',
B: 'bg-mana-black border border-border-secondary',
R: 'bg-mana-red',
G: 'bg-mana-green',
}
return (
<span className="inline-flex items-center gap-0.5">
{colors.map(c => (
<span key={c} className={`${sz} rounded-full ${DOT_COLORS[c] || 'bg-mana-colorless'}`} />
))}
</span>
)
}
@@ -1,38 +0,0 @@
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
const SIZES = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
return (
<svg
className={`animate-spin ${SIZES[size]} ${className}`}
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="opacity-20"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
)
}
export function PageSpinner() {
return (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
@@ -1,35 +0,0 @@
import { CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
type Variant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
interface StatusBadgeProps {
variant: Variant
label: string
className?: string
}
const STYLES: Record<Variant, string> = {
success: 'bg-status-success/15 text-status-success border-status-success/30',
error: 'bg-status-error/15 text-status-error border-status-error/30',
warning: 'bg-status-warning/15 text-status-warning border-status-warning/30',
info: 'bg-status-info/15 text-status-info border-status-info/30',
neutral: 'bg-bg-tertiary text-text-secondary border-border-primary',
}
const ICONS: Record<Variant, typeof CheckCircle> = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
neutral: Info,
}
export function StatusBadge({ variant, label, className = '' }: StatusBadgeProps) {
const Icon = ICONS[variant]
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border ${STYLES[variant]} ${className}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
)
}
@@ -1,5 +0,0 @@
export { Spinner, PageSpinner } from './Spinner'
export { StatusBadge } from './StatusBadge'
export { CardImage } from './CardImage'
export { ManaSymbols, ColorDots } from './ManaSymbols'
export { EmptyState } from './EmptyState'
@@ -1,16 +0,0 @@
import { Sidebar } from './Sidebar'
interface PageLayoutProps {
children: React.ReactNode
}
export function PageLayout({ children }: PageLayoutProps) {
return (
<div className="min-h-screen bg-bg-primary">
<Sidebar />
<main className="ml-56 min-h-screen">
{children}
</main>
</div>
)
}
@@ -1,51 +0,0 @@
import { NavLink } from 'react-router-dom'
const NAV_ITEMS = [
{ to: '/', label: 'Batch Sim', icon: '⚗️' },
{ to: '/collection', label: 'Collection', icon: '📦' },
{ to: '/decks', label: 'Decks', icon: '🃏' },
{ to: '/autogen', label: 'Auto Gen', icon: '⚡' },
{ to: '/simulator', label: 'Simulator', icon: '🎮' },
{ to: '/coach', label: 'Coach', icon: '🧠' },
{ to: '/training', label: 'Training', icon: '📈' },
]
export function Sidebar() {
return (
<aside className="fixed left-0 top-0 bottom-0 w-56 bg-bg-secondary border-r border-border-primary flex flex-col z-30">
{/* Logo */}
<div className="px-5 py-5 border-b border-border-primary">
<h1 className="text-base font-bold text-text-primary tracking-tight">
Commander AI Lab
</h1>
<p className="text-xs text-text-tertiary mt-0.5">MTG Intelligence Platform</p>
</div>
{/* Nav */}
<nav className="flex-1 py-3 overflow-y-auto">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors duration-150 ${
isActive
? 'bg-accent-blue/10 text-accent-blue border-r-2 border-accent-blue'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-hover'
}`
}
>
<span className="text-base">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
{/* Footer */}
<div className="px-5 py-4 border-t border-border-primary">
<p className="text-xs text-text-tertiary">v4.0 — React + TypeScript</p>
</div>
</aside>
)
}
@@ -1,13 +0,0 @@
import { useEffect, useRef } from 'react'
/** Polls a function at the given interval while `enabled` is true */
export function usePolling(fn: () => void, intervalMs: number, enabled: boolean) {
const savedFn = useRef(fn)
savedFn.current = fn
useEffect(() => {
if (!enabled) return
const id = setInterval(() => savedFn.current(), intervalMs)
return () => clearInterval(id)
}, [intervalMs, enabled])
}
-114
View File
@@ -1,114 +0,0 @@
@import "tailwindcss";
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Design Tokens (Tailwind v4 CSS Theme)
Fresh dark theme with MTG-inspired accents
═══════════════════════════════════════════════════════════ */
@theme {
/* ── Backgrounds ── */
--color-bg-primary: #0f1117;
--color-bg-secondary: #161921;
--color-bg-tertiary: #1c2030;
--color-bg-elevated: #222738;
--color-bg-hover: #2a3048;
/* ── Borders ── */
--color-border-primary: #2a2f42;
--color-border-secondary: #363c54;
--color-border-accent: #4f6ef7;
/* ── Text ── */
--color-text-primary: #e8eaf0;
--color-text-secondary: #9ba1b8;
--color-text-tertiary: #6b7190;
--color-text-inverse: #0f1117;
/* ── Accents ── */
--color-accent-blue: #4f6ef7;
--color-accent-blue-hover: #6b84ff;
--color-accent-teal: #2dd4bf;
--color-accent-teal-hover: #5eead4;
--color-accent-purple: #a78bfa;
--color-accent-amber: #fbbf24;
--color-accent-red: #f87171;
--color-accent-green: #4ade80;
/* ── MTG Mana Colors ── */
--color-mana-white: #f9faf4;
--color-mana-blue: #0e67ab;
--color-mana-black: #2b2424;
--color-mana-red: #d3202a;
--color-mana-green: #00733e;
--color-mana-colorless: #706f6f;
/* ── Status ── */
--color-status-success: #4ade80;
--color-status-warning: #fbbf24;
--color-status-error: #f87171;
--color-status-info: #60a5fa;
/* ── Radius ── */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow-blue: 0 0 20px rgba(79, 110, 247, 0.15);
--shadow-glow-teal: 0 0 20px rgba(45, 212, 191, 0.15);
}
/* ── Base Styles ── */
html {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* ── Utility animations ── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(79, 110, 247, 0.2); }
50% { box-shadow: 0 0 16px 4px rgba(79, 110, 247, 0.15); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-pulse-glow {
animation: pulse-glow 2s infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -1,436 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import {
Search, Wand2, Save, AlertCircle,
ChevronDown, Sparkles, RefreshCw, Check
} from 'lucide-react'
import { Spinner, StatusBadge, ManaSymbols, CardImage, ColorDots } from '../components/common'
import { deckgenApi } from '../api'
import type { DeckGenV3Status, DeckGenV3Result, DeckGenV3Card, CommanderSearchResult } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Commander Search ──────────────────────────────────────────
function CommanderSearch({ onSelect }: { onSelect: (c: CommanderSearchResult) => void }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CommanderSearchResult[]>([])
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
function handleSearch(q: string) {
setQuery(q)
clearTimeout(debounceRef.current)
if (q.length < 2) { setResults([]); return }
debounceRef.current = setTimeout(async () => {
setSearching(true)
try {
const r = await deckgenApi.searchCommanders(q)
setResults(r)
} catch { setResults([]) }
setSearching(false)
}, 300)
}
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search for a commander..."
value={query}
onChange={e => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
{searching && <Spinner size="sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-accent-blue" />}
</div>
{results.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-bg-elevated border border-border-primary rounded-xl shadow-lg max-h-80 overflow-y-auto">
{results.map(c => (
<button
key={c.scryfall_id}
onClick={() => { onSelect(c); setQuery(c.name); setResults([]) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-hover text-left transition-colors"
>
<CardImage src={c.image_url} alt={c.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
<p className="text-xs text-text-secondary truncate">{c.type_line}</p>
</div>
<ColorDots colors={c.color_identity} />
{c.in_collection && (
<span className="text-[10px] px-1.5 py-0.5 bg-accent-green/15 text-accent-green rounded">Owned</span>
)}
</button>
))}
</div>
)}
</div>
)
}
// ── Generated Deck View ───────────────────────────────────────
function GeneratedDeckView({ result, onCommit }: { result: DeckGenV3Result; onCommit: () => void }) {
const [view, setView] = useState<'visual' | 'list'>('visual')
const [committing, setCommitting] = useState(false)
const [selectedCard, setSelectedCard] = useState<DeckGenV3Card | null>(null)
// Group by category
const grouped = result.cards.reduce<Record<string, DeckGenV3Card[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const curveData = Object.entries(result.stats.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
async function handleCommit() {
setCommitting(true)
try { onCommit() } catch { /* ignore */ }
setCommitting(false)
}
return (
<div className="space-y-5 animate-fade-in">
{/* Commander header */}
<div className="flex gap-5 bg-bg-secondary rounded-xl border border-border-primary p-5">
<CardImage src={result.commander.image_url} alt={result.commander.name} size="lg" />
<div className="flex-1 space-y-3">
<div>
<h2 className="text-xl font-bold text-text-primary">{result.commander.name}</h2>
<p className="text-sm text-text-secondary">{result.commander.type_line}</p>
<div className="flex items-center gap-3 mt-2">
<ManaSymbols cost={result.commander.mana_cost} size="md" />
<ColorDots colors={result.commander.color_identity} size="md" />
</div>
</div>
<p className="text-sm text-text-primary whitespace-pre-wrap">{result.commander.oracle_text}</p>
<div className="flex items-center gap-4 text-xs text-text-secondary">
<span>Strategy: <span className="text-accent-blue font-medium">{result.strategy}</span></span>
<span>Bracket: <span className="text-accent-amber font-medium">{result.bracket}</span></span>
<span>Model: <span className="text-accent-purple font-medium">{result.model}</span></span>
</div>
{result.substitution && (
<div className="flex items-center gap-3 text-xs">
<span className="text-accent-green">{result.substitution.owned} owned</span>
<span className="text-accent-amber">{result.substitution.substituted} substituted</span>
<span className="text-accent-red">{result.substitution.missing} missing</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button onClick={handleCommit} disabled={committing}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-green/15 text-accent-green text-sm font-medium rounded-lg hover:bg-accent-green/25 border border-accent-green/30 transition-colors disabled:opacity-50">
{committing ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
Save to Decks
</button>
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button onClick={() => setView('visual')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'visual' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
Visual
</button>
<button onClick={() => setView('list')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
List
</button>
</div>
{/* Stats summary */}
<div className="flex-1" />
<span className="text-xs text-text-tertiary">{result.stats.card_count} cards</span>
<span className="text-xs text-accent-teal font-medium">${result.stats.total_price.toFixed(2)}</span>
</div>
{/* Mana curve mini */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">Mana Curve</h4>
<div className="h-28">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={curveData} barSize={20}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 10 }} />
<YAxis hide />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 11 }} />
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{curveData.map((_, i) => <Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Card grid/list */}
{view === 'visual' ? (
<div className="space-y-5">
{Object.entries(grouped).map(([cat, cards]) => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">{cat} ({cards.length})</h4>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-2">
{cards.map(card => (
<div key={card.name} className="group cursor-pointer relative" onClick={() => setSelectedCard(card)}>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
{card.is_substitute && (
<div className="absolute top-1 right-1 w-4 h-4 bg-accent-amber rounded-full flex items-center justify-center">
<RefreshCw className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
{card.owned && (
<div className="absolute top-1 left-1 w-4 h-4 bg-accent-green rounded-full flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Category</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Role</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.cards.map(card => (
<tr key={card.name} className="hover:bg-bg-hover transition-colors">
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.quantity}x {card.name}</span>
{card.is_substitute && (
<span className="ml-2 text-[10px] text-accent-amber">sub for {card.original_name}</span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.category}</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.role}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-right text-accent-teal text-xs">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
<td className="px-4 py-2.5">
<span className={`text-[10px] px-2 py-0.5 rounded ${card.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
{card.owned ? `Owned (${card.owned_qty})` : 'Missing'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Card detail popup */}
{selectedCard && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setSelectedCard(null)}>
<div className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-2xl w-full mx-4 p-6 animate-fade-in" onClick={e => e.stopPropagation()}>
<div className="flex gap-5">
<CardImage src={selectedCard.image_url} alt={selectedCard.name} size="lg" />
<div className="flex-1 space-y-3">
<h3 className="text-lg font-bold text-text-primary">{selectedCard.name}</h3>
<p className="text-sm text-text-secondary">{selectedCard.type_line}</p>
<ManaSymbols cost={selectedCard.mana_cost} size="md" />
<p className="text-sm text-text-primary whitespace-pre-wrap">{selectedCard.reason}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-text-tertiary">Role: <span className="text-accent-blue">{selectedCard.role}</span></span>
{selectedCard.is_substitute && (
<span className="text-accent-amber">Substituted for {selectedCard.original_name} ({(selectedCard.similarity_score ?? 0 * 100).toFixed(0)}%)</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
// ── Main Auto Gen Page ────────────────────────────────────────
export function AutoGenPage() {
const [status, setStatus] = useState<DeckGenV3Status | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [result, setResult] = useState<DeckGenV3Result | null>(null)
const [commander, setCommander] = useState<CommanderSearchResult | null>(null)
const [strategy, setStrategy] = useState('')
const [bracket, setBracket] = useState(3)
const [budget, setBudget] = useState<string>('')
const [useCollection, setUseCollection] = useState(true)
const [runSub, setRunSub] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
deckgenApi.getV3Status().then(s => { setStatus(s); setLoading(false) }).catch(() => setLoading(false))
}, [])
async function handleGenerate() {
if (!commander) return
setGenerating(true)
setError(null)
setResult(null)
try {
const res = await deckgenApi.generateDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
setResult(res)
} catch (e) {
setError(e instanceof Error ? e.message : 'Generation failed')
}
setGenerating(false)
}
async function handleCommit() {
if (!commander || !result) return
try {
await deckgenApi.commitDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
} catch { /* ignore */ }
}
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Sparkles className="w-6 h-6 text-accent-purple" />
Auto Gen V3
</h1>
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck generation with Perplexity</p>
</div>
{/* Status bar */}
<div className="flex items-center gap-3">
<StatusBadge
variant={status?.initialized ? 'success' : 'error'}
label={status?.initialized ? 'Ready' : 'Not Initialized'}
/>
<StatusBadge
variant={status?.pplx_configured ? 'success' : 'warning'}
label={status?.pplx_configured ? 'Perplexity Connected' : 'Perplexity Not Set'}
/>
{status?.embeddings_loaded && (
<StatusBadge variant="info" label={`${status.embedding_cards.toLocaleString()} embeddings`} />
)}
</div>
{!result ? (
/* Config form */
<div className="max-w-2xl space-y-5">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Commander</label>
<CommanderSearch onSelect={setCommander} />
{commander && (
<div className="mt-2 flex items-center gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<CardImage src={commander.image_url} alt={commander.name} size="sm" />
<div>
<p className="text-sm font-medium text-text-primary">{commander.name}</p>
<p className="text-xs text-text-secondary">{commander.type_line}</p>
</div>
<ColorDots colors={commander.color_identity} />
</div>
)}
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Strategy (optional)</label>
<input
type="text"
placeholder="e.g. Voltron, Aristocrats, Storm..."
value={strategy}
onChange={e => setStrategy(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Target Bracket</label>
<select
value={bracket}
onChange={e => setBracket(Number(e.target.value))}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value={1}>1 — Casual</option>
<option value={2}>2 — Low Power</option>
<option value={3}>3 — Mid Power</option>
<option value={4}>4 — High Power</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Budget (USD, optional)</label>
<input
type="number"
placeholder="No limit"
value={budget}
onChange={e => setBudget(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={useCollection} onChange={e => setUseCollection(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Use collection cards</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={runSub} onChange={e => setRunSub(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Run substitution pass</span>
</label>
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-3 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<button
onClick={handleGenerate}
disabled={!commander || generating || !status?.initialized}
className="flex items-center gap-2 px-6 py-3 bg-accent-blue text-white text-sm font-semibold rounded-lg hover:bg-accent-blue-hover shadow-glow-blue transition-all disabled:opacity-50 disabled:shadow-none"
>
{generating ? (
<><Spinner size="sm" /> Generating...</>
) : (
<><Wand2 className="w-4 h-4" /> Generate Deck</>
)}
</button>
</div>
) : (
/* Results */
<div>
<button onClick={() => setResult(null)}
className="flex items-center gap-2 mb-4 text-sm text-text-secondary hover:text-text-primary transition-colors">
<ChevronDown className="w-4 h-4 rotate-90" />
Back to Config
</button>
<GeneratedDeckView result={result} onCommit={handleCommit} />
</div>
)}
</div>
)
}
@@ -1,415 +0,0 @@
import { useState, useEffect } from 'react'
import {
FlaskConical, Play, Clock, Trophy,
Upload, Link, FileText, History, ChevronRight, AlertCircle,
Check
} from 'lucide-react'
import { Spinner } from '../components/common'
import { labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Results Chart ─────────────────────────────────────────────
function ResultsChart({ result }: { result: LabResult }) {
const data = result.decks
.sort((a, b) => b.win_rate - a.win_rate)
.map(d => ({
name: d.name.length > 20 ? d.name.slice(0, 18) + '...' : d.name,
win_rate: Math.round(d.win_rate * 100),
fullName: d.name,
}))
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" barSize={20} margin={{ left: 10, right: 20 }}>
<XAxis type="number" domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" width={150} tick={{ fill: '#9ba1b8', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']}
labelFormatter={(label: unknown) => data.find(d => d.name === String(label))?.fullName || String(label)}
/>
<Bar dataKey="win_rate" radius={[0, 4, 4, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={i === 0 ? '#4ade80' : i === 1 ? '#4f6ef7' : '#363c54'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── Deck Selector ─────────────────────────────────────────────
function DeckSelector({
decks,
selected,
onToggle,
precons,
onInstallPrecon,
}: {
decks: LabDeck[]
selected: Set<string>
onToggle: (name: string) => void
precons: PreconDeck[]
onInstallPrecon: (name: string) => void
}) {
const [showPrecons, setShowPrecons] = useState(false)
const [installing, setInstalling] = useState<string | null>(null)
async function handleInstall(name: string) {
setInstalling(name)
await onInstallPrecon(name)
setInstalling(null)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-text-primary">Available Decks</h3>
{decks.length === 0 ? (
<p className="text-xs text-text-tertiary">No decks available. Import or install precons.</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{decks.map(d => (
<label key={d.name} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover cursor-pointer transition-colors">
<input
type="checkbox"
checked={selected.has(d.name)}
onChange={() => onToggle(d.name)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-text-primary truncate block">{d.name}</span>
<span className="text-[10px] text-text-tertiary">{d.source} · {d.card_count} cards</span>
</div>
</label>
))}
</div>
)}
<button
onClick={() => setShowPrecons(!showPrecons)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-bg-tertiary rounded-lg text-text-secondary hover:bg-bg-hover border border-border-primary transition-colors"
>
Precon Decks
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPrecons ? 'rotate-90' : ''}`} />
</button>
{showPrecons && (
<div className="space-y-1 max-h-48 overflow-y-auto animate-fade-in">
{precons.map(p => (
<div key={p.name} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-bg-tertiary">
<span className="flex-1 text-xs text-text-secondary truncate">{p.name}</span>
{p.installed ? (
<span className="text-[10px] text-accent-green flex items-center gap-1"><Check className="w-3 h-3" /> Installed</span>
) : (
<button
onClick={() => handleInstall(p.name)}
disabled={installing === p.name}
className="text-[10px] text-accent-blue hover:text-accent-blue-hover transition-colors"
>
{installing === p.name ? <Spinner size="sm" /> : 'Install'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
// ── Import Panel ──────────────────────────────────────────────
function ImportPanel({ onImported }: { onImported: () => void }) {
const [importUrl, setImportUrl] = useState('')
const [importText, setImportText] = useState('')
const [importName, setImportName] = useState('')
const [importing, setImporting] = useState(false)
const [importMode, setImportMode] = useState<'url' | 'text'>('url')
async function handleImport() {
setImporting(true)
try {
if (importMode === 'url' && importUrl.trim()) {
await labApi.importDeckFromUrl(importUrl.trim())
} else if (importMode === 'text' && importText.trim()) {
await labApi.importDeckFromText(importText.trim(), importName || undefined)
}
setImportUrl('')
setImportText('')
setImportName('')
onImported()
} catch { /* ignore */ }
setImporting(false)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-4 h-4 text-accent-blue" />
Import Deck
</h3>
<div className="flex gap-1">
<button onClick={() => setImportMode('url')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'url' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<Link className="w-3 h-3 inline mr-1" />URL
</button>
<button onClick={() => setImportMode('text')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'text' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<FileText className="w-3 h-3 inline mr-1" />Text
</button>
</div>
{importMode === 'url' ? (
<input
type="text"
placeholder="Moxfield, Archidekt, or deck URL..."
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
) : (
<>
<input
type="text"
placeholder="Deck name"
value={importName}
onChange={e => setImportName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n..."}
value={importText}
onChange={e => setImportText(e.target.value)}
rows={5}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
</>
)}
<button onClick={handleImport} disabled={importing}
className="w-full px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : 'Import'}
</button>
</div>
)
}
// ── Main Batch Sim Page ───────────────────────────────────────
export function BatchSimPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [precons, setPrecons] = useState<PreconDeck[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [games, setGames] = useState(100)
const [status, setStatus] = useState<LabStatus | null>(null)
const [result, setResult] = useState<LabResult | null>(null)
const [history, setHistory] = useState<LabHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showHistory, setShowHistory] = useState(false)
async function fetchDecks() {
try {
const [d, p] = await Promise.all([labApi.getLabDecks(), labApi.getPrecons()])
setDecks(d)
setPrecons(p)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => {
fetchDecks()
labApi.getLabStatus().then(setStatus).catch(() => {})
labApi.getLabHistory().then(setHistory).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
try {
const s = await labApi.getLabStatus()
setStatus(s)
if (!s.running && s.run_id) {
const r = await labApi.getLabResult()
setResult(r)
labApi.getLabHistory().then(setHistory).catch(() => {})
}
} catch { /* ignore */ }
}, 2000, status?.running || false)
function toggleDeck(name: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
async function handleStart() {
if (selected.size < 2) { setError('Select at least 2 decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
await labApi.startBatchSim({ decks: [...selected], games })
const s = await labApi.getLabStatus()
setStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleInstallPrecon(name: string) {
try {
await labApi.installPrecon(name)
fetchDecks()
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<FlaskConical className="w-6 h-6 text-accent-teal" />
Batch Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run mass simulations between decks</p>
</div>
<div className="flex gap-5">
{/* Left sidebar: deck selection + import */}
<div className="w-72 flex-shrink-0 space-y-4">
{loading ? (
<div className="flex items-center justify-center h-32"><Spinner size="md" className="text-accent-blue" /></div>
) : (
<DeckSelector
decks={decks}
selected={selected}
onToggle={toggleDeck}
precons={precons}
onInstallPrecon={handleInstallPrecon}
/>
)}
<ImportPanel onImported={fetchDecks} />
</div>
{/* Main content */}
<div className="flex-1 space-y-5">
{/* Config + Start */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<div className="flex items-center gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number"
min={10}
max={10000}
step={10}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-28 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="flex-1">
<p className="text-xs text-text-tertiary">Selected: {selected.size} decks</p>
</div>
{status?.running ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Spinner size="sm" className="text-accent-blue" />
<span className="text-sm text-text-secondary">
{status.games_completed} / {status.total_games} games
</span>
</div>
<div className="w-48 h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all"
style={{ width: `${status.total_games ? (status.games_completed / status.total_games) * 100 : 0}%` }}
/>
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || selected.size < 2}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-teal/15 text-accent-teal text-sm font-semibold rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Simulation
</button>
)}
</div>
{error && (
<div className="mt-3 flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary">{result.total_games} games · {new Date(result.timestamp).toLocaleString()}</span>
</div>
<ResultsChart result={result} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium uppercase tracking-wider">Deck</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Win Rate</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Avg Turns</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.decks.sort((a, b) => b.win_rate - a.win_rate).map((d, i) => (
<tr key={d.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2.5 flex items-center gap-2">
{i === 0 && <Trophy className="w-3.5 h-3.5 text-accent-amber" />}
<span className="font-medium text-text-primary">{d.name}</span>
</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.wins}</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.games}</td>
<td className="px-3 py-2.5 text-right font-semibold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.avg_turns.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* History */}
<div>
<button onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
<History className="w-4 h-4" />
Run History ({history.length})
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showHistory ? 'rotate-90' : ''}`} />
</button>
{showHistory && history.length > 0 && (
<div className="mt-3 space-y-2 animate-fade-in">
{history.map(h => (
<div key={h.run_id} className="flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary">
<Clock className="w-4 h-4 text-text-tertiary" />
<div className="flex-1">
<p className="text-sm text-text-primary">{h.decks.join(' vs ')}</p>
<p className="text-xs text-text-tertiary">{new Date(h.timestamp).toLocaleString()} · {h.total_games} games</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}
@@ -1,949 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
GraduationCap, MessageSquare, Send, Sparkles, AlertCircle, X, Check,
ChevronRight, ChevronDown, Download, Settings2, Search, Zap,
Minus, Plus, History, ArrowRightLeft, Lightbulb, DollarSign, Loader2,
ClipboardList, Brain, BarChart3
} from 'lucide-react'
import { Spinner, StatusBadge, EmptyState } from '../components/common'
import { coachApi } from '../api'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachMessage, CoachGoals, CoachApplyResult, CoachCardLikeResult
} from '../types'
// ════════════════════════════════════════════════════════════
// Goals Config Panel
// ════════════════════════════════════════════════════════════
function GoalsPanel({
goals, onChange, collapsed, onToggle
}: {
goals: CoachGoals
onChange: (g: CoachGoals) => void
collapsed: boolean
onToggle: () => void
}) {
const strategies = ['aggro', 'control', 'combo', 'midrange', 'stax', 'voltron', 'aristocrats', 'tokens']
const budgets = [
{ value: 'budget', label: 'Budget', desc: 'Under $5/card' },
{ value: 'medium', label: 'Medium', desc: 'Under $20/card' },
{ value: 'no-limit', label: 'No Limit', desc: 'Any price' },
]
const focusOptions = ['ramp', 'card draw', 'removal', 'protection', 'threats', 'mana base', 'combos', 'synergy']
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary">
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-bg-hover transition-colors rounded-xl"
>
<Settings2 className="w-4 h-4 text-accent-teal" />
<span className="text-sm font-medium text-text-primary flex-1">Coaching Goals</span>
{collapsed ? <ChevronRight className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{!collapsed && (
<div className="px-4 pb-4 space-y-4 border-t border-border-primary pt-3">
{/* Power Level Slider */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1 block">
Power Level: {goals.targetPowerLevel ?? 'Any'}
</label>
<input
type="range" min={1} max={10}
value={goals.targetPowerLevel ?? 5}
onChange={e => onChange({ ...goals, targetPowerLevel: parseInt(e.target.value) })}
className="w-full accent-accent-teal"
/>
<div className="flex justify-between text-[10px] text-text-tertiary">
<span>Casual</span><span>Focused</span><span>cEDH</span>
</div>
</div>
{/* Strategy */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Strategy Focus</label>
<div className="flex flex-wrap gap-1.5">
{strategies.map(s => (
<button
key={s}
onClick={() => onChange({ ...goals, metaFocus: goals.metaFocus === s ? null : s })}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
goals.metaFocus === s
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{s}
</button>
))}
</div>
</div>
{/* Budget */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Budget</label>
<div className="grid grid-cols-3 gap-2">
{budgets.map(b => (
<button
key={b.value}
onClick={() => onChange({ ...goals, budget: goals.budget === b.value ? null : b.value })}
className={`px-3 py-2 text-xs rounded-lg border transition-colors ${
goals.budget === b.value
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
<div className="font-medium">{b.label}</div>
<div className="text-[10px] opacity-60">{b.desc}</div>
</button>
))}
</div>
</div>
{/* Focus Areas */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Focus Areas</label>
<div className="flex flex-wrap gap-1.5">
{focusOptions.map(f => {
const active = (goals.focusAreas || []).includes(f)
return (
<button
key={f}
onClick={() => {
const areas = goals.focusAreas || []
onChange({
...goals,
focusAreas: active ? areas.filter(a => a !== f) : [...areas, f]
})
}}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
active
? 'bg-accent-blue/20 border-accent-blue/40 text-accent-blue'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{f}
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Session View — Shows coaching results with apply functionality
// ════════════════════════════════════════════════════════════
function SessionView({
session, deckId, onClose, onApplied
}: {
session: CoachSession
deckId: number
onClose: () => void
onApplied?: () => void
}) {
const [selectedCuts, setSelectedCuts] = useState<Set<string>>(new Set())
const [selectedAdds, setSelectedAdds] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState(false)
const [applyResult, setApplyResult] = useState<CoachApplyResult | null>(null)
const toggleCut = (name: string) => {
const s = new Set(selectedCuts)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedCuts(s)
}
const toggleAdd = (name: string) => {
const s = new Set(selectedAdds)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedAdds(s)
}
const handleApply = async () => {
if (selectedCuts.size === 0 && selectedAdds.size === 0) return
setApplying(true)
try {
const result = await coachApi.applyCoachSuggestions(
session.sessionId, deckId,
[...selectedCuts], [...selectedAdds]
)
setApplyResult(result)
onApplied?.()
} catch (e) {
console.error('Apply failed:', e)
}
setApplying(false)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">Coaching Session</h3>
<p className="text-xs text-text-tertiary">
{new Date(session.timestamp).toLocaleString()} · {session.modelUsed}
{session.promptTokens > 0 && ` · ${session.promptTokens + session.completionTokens} tokens`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Summary */}
{session.summary && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-medium text-text-secondary mb-2 flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5" /> Summary
</h4>
<p className="text-sm text-text-primary leading-relaxed">{session.summary}</p>
</div>
)}
{/* Heuristic Hints */}
{session.heuristicHints?.length > 0 && (
<div className="bg-accent-amber/5 rounded-xl border border-accent-amber/20 p-4">
<h4 className="text-xs font-medium text-accent-amber mb-2 flex items-center gap-1.5">
<Lightbulb className="w-3.5 h-3.5" /> Strategic Tips
</h4>
<ul className="space-y-1.5">
{session.heuristicHints.map((h, i) => (
<li key={i} className="text-sm text-text-primary flex items-start gap-2">
<span className="text-accent-amber mt-0.5">•</span> {h}
</li>
))}
</ul>
</div>
)}
{/* Mana Base Advice */}
{session.manaBaseAdvice && (
<div className="bg-accent-blue/5 rounded-xl border border-accent-blue/20 p-4">
<h4 className="text-xs font-medium text-accent-blue mb-2 flex items-center gap-1.5">
<BarChart3 className="w-3.5 h-3.5" /> Mana Base
</h4>
<p className="text-sm text-text-primary">{session.manaBaseAdvice}</p>
</div>
)}
{/* Suggested Cuts */}
{session.suggestedCuts?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-error mb-2 flex items-center gap-1.5">
<Minus className="w-4 h-4" /> Suggested Cuts ({session.suggestedCuts.length})
</h4>
<div className="space-y-2">
{session.suggestedCuts.map((cut, i) => (
<div
key={i}
onClick={() => toggleCut(cut.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedCuts.has(cut.cardName)
? 'bg-status-error/10 border-status-error/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedCuts.has(cut.cardName)
? 'bg-status-error border-status-error text-white'
: 'border-border-secondary'
}`}>
{selectedCuts.has(cut.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{cut.cardName}</p>
<p className="text-xs text-text-secondary mt-0.5">{cut.reason}</p>
{cut.replacementOptions?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Replace with: {cut.replacementOptions.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Suggested Adds */}
{session.suggestedAdds?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Suggested Adds ({session.suggestedAdds.length})
</h4>
<div className="space-y-2">
{session.suggestedAdds.map((add, i) => (
<div
key={i}
onClick={() => toggleAdd(add.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedAdds.has(add.cardName)
? 'bg-status-success/10 border-status-success/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedAdds.has(add.cardName)
? 'bg-status-success border-status-success text-white'
: 'border-border-secondary'
}`}>
{selectedAdds.has(add.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{add.cardName}</p>
{add.role && (
<span className="px-1.5 py-0.5 text-[10px] bg-accent-blue/15 text-accent-blue rounded">
{add.role}
</span>
)}
</div>
<p className="text-xs text-text-secondary mt-0.5">{add.reason}</p>
{add.synergyWith?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Synergy: {add.synergyWith.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Apply Button */}
{!applyResult && (selectedCuts.size > 0 || selectedAdds.size > 0) && (
<div className="sticky bottom-0 bg-bg-primary/95 backdrop-blur-sm border-t border-border-primary py-3 -mx-6 px-6">
<button
onClick={handleApply}
disabled={applying}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
{applying ? <Spinner size="sm" /> : <ArrowRightLeft className="w-4 h-4" />}
Apply {selectedCuts.size > 0 && `${selectedCuts.size} cuts`}
{selectedCuts.size > 0 && selectedAdds.size > 0 && ' + '}
{selectedAdds.size > 0 && `${selectedAdds.size} adds`} to Deck
</button>
</div>
)}
{/* Apply Results */}
{applyResult && (
<div className="bg-status-success/5 rounded-xl border border-status-success/20 p-4">
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Check className="w-4 h-4" /> Changes Applied
</h4>
<p className="text-sm text-text-primary">
{applyResult.total_cuts > 0 && `Removed ${applyResult.total_cuts} card(s). `}
{applyResult.total_adds > 0 && `Added ${applyResult.total_adds} card(s). `}
{applyResult.errors.length > 0 && `${applyResult.errors.length} error(s).`}
</p>
{applyResult.errors.length > 0 && (
<ul className="mt-2 text-xs text-status-error">
{applyResult.errors.map((e, i) => (
<li key={i}>{e.name}: {e.error}</li>
))}
</ul>
)}
</div>
)}
{/* Raw Explanation */}
{session.rawTextExplanation && (
<details className="group">
<summary className="text-xs text-text-tertiary cursor-pointer hover:text-text-secondary">
Full analysis...
</summary>
<p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap leading-relaxed bg-bg-secondary rounded-lg p-4 border border-border-primary">
{session.rawTextExplanation}
</p>
</details>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Cards Like Search
// ════════════════════════════════════════════════════════════
function CardsLikePanel() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CoachCardLikeResult[]>([])
const [searching, setSearching] = useState(false)
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
try {
const data = await coachApi.searchCardsLike(query.trim(), undefined, 12)
setResults(data.results)
} catch { /* ignore */ }
setSearching(false)
}
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="Enter a card name..."
className="w-full pl-9 pr-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue"
/>
</div>
<button
onClick={handleSearch}
disabled={searching || !query.trim()}
className="px-4 py-2 bg-accent-teal/15 text-accent-teal text-sm font-medium rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50"
>
{searching ? <Spinner size="sm" /> : 'Search'}
</button>
</div>
{results.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{results.map((card, i) => (
<div key={i} className="flex items-start gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary truncate">{card.name}</p>
<span className="text-[10px] text-text-tertiary whitespace-nowrap">
{(card.similarity * 100).toFixed(0)}%
</span>
</div>
<p className="text-[11px] text-text-secondary mt-0.5">{card.types}</p>
{card.text && (
<p className="text-[10px] text-text-tertiary mt-1 line-clamp-2">{card.text}</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-[10px]">
{card.tcg_price != null && (
<span className="text-accent-amber flex items-center gap-0.5">
<DollarSign className="w-2.5 h-2.5" />{card.tcg_price.toFixed(2)}
</span>
)}
{card.owned_qty > 0 && (
<span className="text-status-success">Owned: {card.owned_qty}</span>
)}
<span className="text-text-tertiary">MV: {card.mana_value}</span>
</div>
</div>
{card.image_url && (
<img src={card.image_url} alt={card.name} className="w-14 h-auto rounded" />
)}
</div>
))}
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Live Chat View
// ════════════════════════════════════════════════════════════
function LiveChat({
deckId, deckName, goals, onClose
}: {
deckId: string
deckName: string
goals: CoachGoals
onClose: () => void
}) {
const [messages, setMessages] = useState<CoachMessage[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [streamText, setStreamText] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streamText])
const sendMessage = async () => {
if (!input.trim() || streaming) return
const userMsg: CoachMessage = { role: 'user', content: input.trim() }
const allMessages = [...messages, userMsg]
setMessages(allMessages)
setInput('')
setStreaming(true)
setStreamText('')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? {
targetPowerLevel: goals.targetPowerLevel,
metaFocus: goals.metaFocus,
budget: goals.budget,
focusAreas: goals.focusAreas,
}
: undefined
try {
abortRef.current = new AbortController()
const fullText = await coachApi.coachChatStream(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict,
(text) => setStreamText(text),
abortRef.current.signal
)
setMessages(prev => [...prev, { role: 'assistant', content: fullText }])
setStreamText('')
} catch (e) {
if ((e as Error).name !== 'AbortError') {
// Fallback to non-streaming
try {
const resp = await coachApi.coachChat(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict
)
setMessages(prev => [...prev, { role: 'assistant', content: resp.content }])
} catch (e2) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Error: ${(e2 as Error).message || 'Failed to get response'}`
}])
}
}
setStreamText('')
}
setStreaming(false)
}
return (
<div className="flex flex-col h-[calc(100vh-180px)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border-primary">
<div>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-accent-blue" />
Chat: {deckName}
</h3>
<p className="text-[10px] text-text-tertiary">
{goals.metaFocus && `${goals.metaFocus} · `}
{goals.targetPowerLevel && `Power ${goals.targetPowerLevel} · `}
{goals.budget && `${goals.budget}`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && !streaming && (
<div className="text-center py-12">
<GraduationCap className="w-10 h-10 text-text-tertiary mx-auto mb-3" />
<p className="text-sm text-text-secondary">Ask your coach anything about this deck.</p>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
{[
'What should I cut?',
'How is my mana curve?',
'Suggest win conditions',
'Rate my removal suite',
].map(q => (
<button
key={q}
onClick={() => { setInput(q); }}
className="px-3 py-1.5 text-xs bg-bg-secondary border border-border-primary rounded-full text-text-secondary hover:text-text-primary hover:border-border-secondary transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${
msg.role === 'user'
? 'bg-accent-blue/15 text-text-primary border border-accent-blue/20'
: 'bg-bg-secondary text-text-primary border border-border-primary'
}`}>
{msg.content}
</div>
</div>
))}
{/* Streaming indicator */}
{streaming && (
<div className="flex justify-start">
<div className="max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed bg-bg-secondary text-text-primary border border-border-primary">
{streamText || (
<span className="flex items-center gap-2 text-text-tertiary">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Thinking...
</span>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-border-primary p-3">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
placeholder="Ask the coach..."
disabled={streaming}
className="flex-1 px-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue disabled:opacity-50"
/>
<button
onClick={sendMessage}
disabled={streaming || !input.trim()}
className="px-3 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}
// ════════════════════════════════════════════════════════════
// Main Coach Page
// ════════════════════════════════════════════════════════════
type Tab = 'decks' | 'chat' | 'session' | 'history' | 'search'
export function CoachPage() {
const [status, setStatus] = useState<CoachStatus | null>(null)
const [decks, setDecks] = useState<CoachDeck[]>([])
const [sessions, setSessions] = useState<CoachSessionSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// UI state
const [tab, setTab] = useState<Tab>('decks')
const [selectedDeck, setSelectedDeck] = useState<CoachDeck | null>(null)
const [selectedSession, setSelectedSession] = useState<CoachSession | null>(null)
const [goals, setGoals] = useState<CoachGoals>({})
const [goalsCollapsed, setGoalsCollapsed] = useState(true)
const [generatingReport, setGeneratingReport] = useState(false)
const [runningSession, setRunningSession] = useState<number | null>(null)
const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false)
const loadData = useCallback(async () => {
try {
const [s, d, sess] = await Promise.all([
coachApi.getCoachStatus(),
coachApi.getCoachDecks().catch(() => []),
coachApi.getCoachSessions().catch(() => ({ sessions: [] })),
])
setStatus(s)
setDecks(Array.isArray(d) ? d : [])
setSessions(Array.isArray(sess) ? sess : (sess as { sessions: CoachSessionSummary[] }).sessions || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadData() }, [loadData])
const handleDownloadEmbeddings = async () => {
setDownloadingEmbeddings(true)
try {
await coachApi.downloadEmbeddings()
const s = await coachApi.getCoachStatus()
setStatus(s)
} catch { /* ignore */ }
setDownloadingEmbeddings(false)
}
const handleGenerateReports = async () => {
setGeneratingReport(true)
setError(null)
try {
await coachApi.generateReport()
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Report generation failed')
}
setGeneratingReport(false)
}
const handleRunSession = async (deck: CoachDeck) => {
setRunningSession(deck.deck_id)
setError(null)
try {
const deckSlug = deck.deck_name.toLowerCase().replace(/\s+/g, '-')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? goals : undefined
const session = await coachApi.runCoachSession(deckSlug, goalsDict as Record<string, unknown>)
setSelectedSession(session)
setSelectedDeck(deck)
setTab('session')
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Session failed')
}
setRunningSession(null)
}
const handleOpenChat = (deck: CoachDeck) => {
setSelectedDeck(deck)
setTab('chat')
}
const handleViewSession = async (sessionId: string) => {
try {
const s = await coachApi.getCoachSession(sessionId)
setSelectedSession(s)
// Find the matching deck
const matchDeck = decks.find(d =>
d.deck_name.toLowerCase().replace(/\s+/g, '-') === s.deckId.toLowerCase() ||
d.deck_name.toLowerCase() === s.deckId.toLowerCase()
)
if (matchDeck) setSelectedDeck(matchDeck)
setTab('session')
} catch { /* ignore */ }
}
if (loading) {
return (
<div className="p-6 flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<GraduationCap className="w-6 h-6 text-accent-amber" />
Deck Coach
</h1>
<p className="text-sm text-text-secondary mt-0.5">
AI-powered deck analysis, coaching, and live chat
</p>
</div>
</div>
{/* Status Bar */}
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge
variant={status?.llmConnected ? 'success' : 'warning'}
label={status?.llmConnected ? `LLM: ${status.llmModel || 'Connected'}` : 'LLM: Disconnected'}
/>
<StatusBadge
variant={status?.embeddingsLoaded ? 'success' : 'neutral'}
label={status?.embeddingsLoaded ? `${(status.embeddingCards || 0).toLocaleString()} embeddings` : 'No embeddings'}
/>
{status?.deckReportsAvailable != null && status.deckReportsAvailable > 0 && (
<StatusBadge variant="success" label={`${status.deckReportsAvailable} reports`} />
)}
{!status?.embeddingsLoaded && (
<button onClick={handleDownloadEmbeddings} disabled={downloadingEmbeddings}
className="flex items-center gap-1.5 text-xs text-accent-blue hover:text-accent-blue-hover transition-colors disabled:opacity-50">
{downloadingEmbeddings ? <Spinner size="sm" /> : <Download className="w-3 h-3" />}
Download Embeddings
</button>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />{error}
<button onClick={() => setError(null)} className="ml-auto"><X className="w-3 h-3" /></button>
</div>
)}
{/* Tab Nav */}
<div className="flex gap-1 bg-bg-secondary rounded-lg p-1 border border-border-primary">
{([
{ id: 'decks', label: 'Decks', icon: ClipboardList },
{ id: 'history', label: 'Sessions', icon: History },
{ id: 'search', label: 'Cards Like', icon: Search },
] as { id: Tab; label: string; icon: typeof ClipboardList }[]).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
tab === t.id
? 'bg-bg-primary text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
{t.id === 'history' && sessions.length > 0 && (
<span className="text-[10px] bg-bg-tertiary px-1.5 rounded-full">{sessions.length}</span>
)}
</button>
))}
{tab === 'chat' && selectedDeck && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-blue shadow-sm">
<MessageSquare className="w-3.5 h-3.5" />
Chat
</div>
)}
{tab === 'session' && selectedSession && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-amber shadow-sm">
<Sparkles className="w-3.5 h-3.5" />
Results
</div>
)}
</div>
{/* Goals Panel (shown when on decks tab) */}
{tab === 'decks' && (
<GoalsPanel
goals={goals}
onChange={setGoals}
collapsed={goalsCollapsed}
onToggle={() => setGoalsCollapsed(!goalsCollapsed)}
/>
)}
{/* ── Decks Tab ─────────────────────────────────────── */}
{tab === 'decks' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-text-primary">Your Decks</h2>
<button
onClick={handleGenerateReports}
disabled={generatingReport}
className="flex items-center gap-1.5 text-xs text-accent-amber hover:text-accent-amber transition-colors disabled:opacity-50"
>
{generatingReport ? <Spinner size="sm" /> : <Zap className="w-3 h-3" />}
Generate Reports
</button>
</div>
{decks.length === 0 ? (
<EmptyState
icon={GraduationCap}
title="No decks available"
description="Create decks in the Deck Builder to start coaching."
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{decks.map(deck => (
<div key={deck.deck_id} className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{deck.deck_name}</h3>
<p className="text-xs text-text-secondary">{deck.commander}</p>
</div>
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
<span>{deck.card_count} cards</span>
{deck.has_report && (
<span className="flex items-center gap-1 text-status-success">
<Check className="w-2.5 h-2.5" /> Report
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleRunSession(deck)}
disabled={runningSession === deck.deck_id || !status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-amber/15 text-accent-amber text-xs font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50"
>
{runningSession === deck.deck_id ? <Spinner size="sm" /> : <Sparkles className="w-3 h-3" />}
Analyze
</button>
<button
onClick={() => handleOpenChat(deck)}
disabled={!status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue/15 text-accent-blue text-xs font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-50"
>
<MessageSquare className="w-3 h-3" />
Chat
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── Chat Tab ──────────────────────────────────────── */}
{tab === 'chat' && selectedDeck && (
<LiveChat
deckId={selectedDeck.deck_name.toLowerCase().replace(/\s+/g, '-')}
deckName={selectedDeck.deck_name}
goals={goals}
onClose={() => setTab('decks')}
/>
)}
{/* ── Session Results Tab ───────────────────────────── */}
{tab === 'session' && selectedSession && selectedDeck && (
<SessionView
session={selectedSession}
deckId={selectedDeck.deck_id}
onClose={() => setTab('decks')}
onApplied={() => loadData()}
/>
)}
{/* ── History Tab ───────────────────────────────────── */}
{tab === 'history' && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-text-primary">Coaching Sessions</h2>
{sessions.length === 0 ? (
<EmptyState
icon={History}
title="No sessions yet"
description="Run an analysis on a deck to create your first coaching session."
/>
) : (
<div className="space-y-2">
{sessions.map(s => (
<button
key={s.sessionId}
onClick={() => handleViewSession(s.sessionId)}
className="w-full flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary hover:border-border-secondary text-left transition-colors"
>
<Sparkles className="w-4 h-4 text-accent-amber flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{s.deckId}</p>
<span className="text-[10px] text-text-tertiary">
{s.cutsCount > 0 && `${s.cutsCount} cuts`}
{s.cutsCount > 0 && s.addsCount > 0 && ' · '}
{s.addsCount > 0 && `${s.addsCount} adds`}
</span>
</div>
<p className="text-xs text-text-tertiary mt-0.5 truncate">{s.summary || 'No summary'}</p>
<p className="text-[10px] text-text-tertiary mt-0.5">{new Date(s.timestamp).toLocaleString()}</p>
</div>
<ChevronRight className="w-4 h-4 text-text-tertiary flex-shrink-0" />
</button>
))}
</div>
)}
</div>
)}
{/* ── Cards Like Tab ────────────────────────────────── */}
{tab === 'search' && (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-text-primary mb-1">Cards Like This</h2>
<p className="text-xs text-text-secondary">
Find similar cards using AI embeddings. Search by card name to discover alternatives.
</p>
</div>
<CardsLikePanel />
</div>
)}
</div>
)
}
@@ -1,484 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Search, Filter, Upload, Download, Camera, RefreshCw,
ChevronLeft, ChevronRight, X, Package, SlidersHorizontal,
Plus, Grid3x3, List
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, CardImage } from '../components/common'
import { collectionApi } from '../api'
import type { CollectionCard, CollectionFilters, SetInfo } from '../types'
// ── Card Detail Modal ─────────────────────────────────────────
function CardDetailModal({ card, onClose }: { card: CollectionCard; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div
className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in"
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between p-5 border-b border-border-primary">
<div>
<h2 className="text-xl font-bold text-text-primary">{card.name}</h2>
<p className="text-sm text-text-secondary mt-0.5">{card.type_line}</p>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 p-5">
{/* Large card image */}
<div className="flex-shrink-0">
<CardImage src={card.image_url} alt={card.name} size="xl" />
</div>
{/* Card info */}
<div className="flex-1 space-y-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Mana Cost</label>
<div className="mt-1"><ManaSymbols cost={card.mana_cost} size="md" /></div>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Oracle Text</label>
<p className="mt-1 text-sm text-text-primary whitespace-pre-wrap leading-relaxed">{card.oracle_text}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<p className="mt-1 text-sm text-text-primary">{card.set_name} ({card.set_code.toUpperCase()})</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<p className={`mt-1 text-sm font-medium capitalize ${
card.rarity === 'mythic' ? 'text-accent-amber' :
card.rarity === 'rare' ? 'text-accent-amber/80' :
card.rarity === 'uncommon' ? 'text-accent-teal' : 'text-text-secondary'
}`}>{card.rarity}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC</label>
<p className="mt-1 text-sm text-text-primary">{card.cmc}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">EDHREC Rank</label>
<p className="mt-1 text-sm text-text-primary">{card.edhrec_rank ?? 'N/A'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Quantity</label>
<p className="mt-1 text-sm text-text-primary">{card.quantity} (+ {card.foil_quantity} foil)</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Price</label>
<p className="mt-1 text-sm text-accent-teal font-semibold">
${card.tcg_price?.toFixed(2) ?? '—'}
{card.foil_price ? <span className="text-text-tertiary ml-2">Foil: ${card.foil_price.toFixed(2)}</span> : null}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// ── Scanner Panel ─────────────────────────────────────────────
function ScannerPanel({ onCardAdded }: { onCardAdded: () => void }) {
const [scanning, setScanning] = useState(false)
const [results, setResults] = useState<import('../types').ScanMatch[]>([])
const [adding, setAdding] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
async function handleScan(file: File) {
setScanning(true)
setResults([])
try {
const res = await collectionApi.scanCard(file)
setResults(res.matches || [])
} catch {
setResults([])
}
setScanning(false)
}
async function handleAdd(match: import('../types').ScanMatch) {
setAdding(match.scryfall_id)
try {
await collectionApi.addScanResult({
name: match.name,
set_code: match.set_code,
scryfall_id: match.scryfall_id,
})
onCardAdded()
} catch { /* ignore */ }
setAdding(null)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2 mb-3">
<Camera className="w-4 h-4 text-accent-blue" />
Card Scanner
</h3>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => e.target.files?.[0] && handleScan(e.target.files[0])}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={scanning}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue/10 text-accent-blue border border-accent-blue/30 rounded-lg text-sm font-medium hover:bg-accent-blue/20 transition-colors disabled:opacity-50"
>
{scanning ? <Spinner size="sm" /> : <Camera className="w-4 h-4" />}
{scanning ? 'Scanning...' : 'Scan a Card'}
</button>
{results.length > 0 && (
<div className="mt-3 space-y-2 max-h-60 overflow-y-auto">
{results.map(m => (
<div key={m.scryfall_id} className="flex items-center gap-3 p-2 rounded-lg bg-bg-tertiary">
<CardImage src={m.image_url} alt={m.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{m.name}</p>
<p className="text-xs text-text-tertiary">{m.set_name} — {(m.confidence * 100).toFixed(0)}%</p>
</div>
<button
onClick={() => handleAdd(m)}
disabled={adding === m.scryfall_id}
className="p-1.5 rounded-lg bg-accent-green/15 text-accent-green hover:bg-accent-green/25 transition-colors"
>
{adding === m.scryfall_id ? <Spinner size="sm" /> : <Plus className="w-4 h-4" />}
</button>
</div>
))}
</div>
)}
</div>
)
}
// ── Main Collection Page ──────────────────────────────────────
export function CollectionPage() {
const [cards, setCards] = useState<CollectionCard[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [sets, setSets] = useState<SetInfo[]>([])
const [selectedCard, setSelectedCard] = useState<CollectionCard | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [showFilters, setShowFilters] = useState(false)
const [importing, setImporting] = useState(false)
const [enriching, setEnriching] = useState(false)
const importRef = useRef<HTMLInputElement>(null)
const [filters, setFilters] = useState<CollectionFilters>({
q: '',
page: 1,
page_size: 48,
sort_by: 'name',
sort_dir: 'asc',
})
const fetchCards = useCallback(async () => {
setLoading(true)
try {
const res = await collectionApi.searchCollection(filters)
setCards(res.cards)
setTotal(res.total)
} catch { /* ignore */ }
setLoading(false)
}, [filters])
useEffect(() => { fetchCards() }, [fetchCards])
useEffect(() => {
collectionApi.getSets().then(setSets).catch(() => {})
}, [])
const totalPages = Math.ceil(total / (filters.page_size || 48))
function updateFilter(key: keyof CollectionFilters, value: string | number | boolean | undefined) {
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
}
async function handleExport() {
const blob = await collectionApi.exportCollection('csv')
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'collection.csv'
a.click()
URL.revokeObjectURL(url)
}
async function handleImport(file: File) {
setImporting(true)
try {
await collectionApi.importCollection(file)
fetchCards()
} catch { /* ignore */ }
setImporting(false)
}
async function handleReEnrich() {
setEnriching(true)
try {
await collectionApi.reEnrichCards()
fetchCards()
} catch { /* ignore */ }
setEnriching(false)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Collection</h1>
<p className="text-sm text-text-secondary mt-0.5">{total.toLocaleString()} cards in your collection</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleReEnrich} disabled={enriching}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{enriching ? <Spinner size="sm" /> : <RefreshCw className="w-3.5 h-3.5" />}
Re-Enrich
</button>
<input ref={importRef} type="file" accept=".csv,.txt" className="hidden"
onChange={e => e.target.files?.[0] && handleImport(e.target.files[0])} />
<button onClick={() => importRef.current?.click()} disabled={importing}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : <Upload className="w-3.5 h-3.5" />}
Import
</button>
<button onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export
</button>
</div>
</div>
<div className="flex gap-5">
{/* Left sidebar: search + filters + scanner */}
<div className="w-72 flex-shrink-0 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search cards..."
value={filters.q || ''}
onChange={e => updateFilter('q', e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
<span className="flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4" />
Filters
</span>
<Filter className={`w-3.5 h-3.5 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{showFilters && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3 animate-fade-in">
{/* Rarity */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<select
value={filters.rarity || ''}
onChange={e => updateFilter('rarity', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="common">Common</option>
<option value="uncommon">Uncommon</option>
<option value="rare">Rare</option>
<option value="mythic">Mythic</option>
</select>
</div>
{/* Color */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Color</label>
<select
value={filters.color || ''}
onChange={e => updateFilter('color', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="W">White</option>
<option value="U">Blue</option>
<option value="B">Black</option>
<option value="R">Red</option>
<option value="G">Green</option>
<option value="C">Colorless</option>
</select>
</div>
{/* Set */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<select
value={filters.set_code || ''}
onChange={e => updateFilter('set_code', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All Sets</option>
{sets.map(s => (
<option key={s.code} value={s.code}>{s.name} ({s.card_count})</option>
))}
</select>
</div>
{/* CMC Range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Min</label>
<input type="number" min="0" max="20"
value={filters.cmc_min ?? ''}
onChange={e => updateFilter('cmc_min', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Max</label>
<input type="number" min="0" max="20"
value={filters.cmc_max ?? ''}
onChange={e => updateFilter('cmc_max', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
</div>
{/* Sort */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Sort By</label>
<select
value={filters.sort_by || 'name'}
onChange={e => updateFilter('sort_by', e.target.value)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="name">Name</option>
<option value="cmc">CMC</option>
<option value="tcg_price">Price</option>
<option value="rarity">Rarity</option>
<option value="set_code">Set</option>
<option value="added_date">Date Added</option>
</select>
</div>
</div>
)}
{/* Scanner */}
<ScannerPanel onCardAdded={fetchCards} />
</div>
{/* Main content: card grid/list */}
<div className="flex-1 min-w-0">
{/* View toggle + pagination */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<Grid3x3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>Page {filters.page} of {totalPages || 1}</span>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.max(1, (p.page || 1) - 1) }))}
disabled={!filters.page || filters.page <= 1}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.min(totalPages, (p.page || 1) + 1) }))}
disabled={(filters.page || 1) >= totalPages}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
) : cards.length === 0 ? (
<EmptyState
icon={Package}
title="No cards found"
description="Try adjusting your search or filters, or import a collection."
/>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{cards.map(card => (
<div
key={card.id}
className="group cursor-pointer"
onClick={() => setSelectedCard(card)}
>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
<div className="mt-1.5 px-0.5">
<p className="text-xs font-medium text-text-primary truncate group-hover:text-accent-blue transition-colors">{card.name}</p>
<div className="flex items-center justify-between">
<span className="text-[10px] text-text-tertiary">{card.set_code.toUpperCase()}</span>
{card.tcg_price != null && (
<span className="text-[10px] text-accent-teal font-medium">${card.tcg_price.toFixed(2)}</span>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Set</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Qty</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{cards.map(card => (
<tr key={card.id} className="hover:bg-bg-hover cursor-pointer transition-colors" onClick={() => setSelectedCard(card)}>
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.name}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.type_line}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.set_code.toUpperCase()}</td>
<td className="px-4 py-2.5 text-text-secondary">{card.quantity}</td>
<td className="px-4 py-2.5 text-right text-accent-teal font-medium">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Card detail modal */}
{selectedCard && <CardDetailModal card={selectedCard} onClose={() => setSelectedCard(null)} />}
</div>
)
}
@@ -1,967 +0,0 @@
import { useState, useEffect } from 'react'
import {
Plus, Trash2, Download, ChevronRight,
BarChart3, Lightbulb, Layers, X, Library,
Globe, XCircle, ShoppingCart,
ChevronDown, ChevronUp, Sparkles
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, ColorDots } from '../components/common'
import { decksApi } from '../api'
import type {
Deck, DeckCard, DeckAnalysis,
EdhRecCard, EdhRecsResponse,
CollectionRecsResponse
} from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Role Badge ──────────────────────────────────────────────
const ROLE_COLORS: Record<string, string> = {
Ramp: 'bg-green-500/15 text-green-400 border-green-500/25',
Draw: 'bg-blue-500/15 text-blue-400 border-blue-500/25',
Removal: 'bg-red-500/15 text-red-400 border-red-500/25',
BoardWipe: 'bg-orange-500/15 text-orange-400 border-orange-500/25',
Tutor: 'bg-purple-500/15 text-purple-400 border-purple-500/25',
Protection: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25',
Finisher: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/25',
Recursion: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25',
Other: 'bg-slate-500/15 text-slate-400 border-slate-500/25',
}
function RoleBadge({ role }: { role: string }) {
const colors = ROLE_COLORS[role] || ROLE_COLORS.Other
return (
<span className={'text-[10px] font-medium px-1.5 py-0.5 rounded border ' + colors}>
{role}
</span>
)
}
// ── Deck Card List ────────────────────────────────────────────
function DeckCardList({ deckId, cards, onUpdate }: { deckId: number; cards: DeckCard[]; onUpdate: () => void }) {
const [removing, setRemoving] = useState<number | null>(null)
// Group cards by category
const grouped = cards.reduce<Record<string, DeckCard[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const categoryOrder = ['Commander', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const ia = categoryOrder.indexOf(a)
const ib = categoryOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
async function handleRemove(cardId: number) {
setRemoving(cardId)
try {
await decksApi.removeCardFromDeck(deckId, cardId)
onUpdate()
} catch { /* ignore */ }
setRemoving(null)
}
return (
<div className="space-y-4">
{sortedCategories.map(cat => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">
{cat} ({grouped[cat].length})
</h4>
<div className="space-y-1">
{grouped[cat].map(card => (
<div key={card.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover group transition-colors">
<span className="text-xs text-text-tertiary w-5 text-right">{card.quantity}x</span>
<ManaSymbols cost={card.mana_cost} />
<span className="flex-1 text-sm text-text-primary truncate">{card.name}</span>
{card.tcg_price != null && (
<span className="text-xs text-accent-teal">${card.tcg_price.toFixed(2)}</span>
)}
{!card.is_commander && (
<button
onClick={() => handleRemove(card.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-text-tertiary hover:text-accent-red transition-all"
>
{removing === card.id ? <Spinner size="sm" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}
// ── Mana Curve Chart ──────────────────────────────────────────
function ManaCurveChart({ analysis }: { analysis: DeckAnalysis }) {
const data = Object.entries(analysis.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
return (
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} barSize={24}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 11 }} />
<YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
labelStyle={{ color: '#9ba1b8' }}
itemStyle={{ color: '#e8eaf0' }}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, i) => (
<Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── EDHREC Recommendations Sub-tab ──────────────────────────
function EdhRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<EdhRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [onlyOwned, setOnlyOwned] = useState(false)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [hoveredCard, setHoveredCard] = useState<EdhRecCard | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getEdhRecs(deckId, onlyOwned)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load EDHREC recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId, onlyOwned])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
setSelectedCards(new Set(data.recommendations.map(r => r.name)))
}
function selectNone() { setSelectedCards(new Set()) }
function selectOwned() {
if (!data) return
setSelectedCards(new Set(data.recommendations.filter(r => r.owned).map(r => r.name)))
}
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from list
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => r.name !== name),
total: data.total - 1,
})
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => !selectedCards.has(r.name)),
total: data.total - selectedCards.size,
})
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
// Group recommendations by role
function groupByRole(recs: EdhRecCard[]): Record<string, EdhRecCard[]> {
const groups: Record<string, EdhRecCard[]> = {}
for (const rec of recs) {
const role = rec.role || 'Other'
if (!groups[role]) groups[role] = []
groups[role].push(rec)
}
return groups
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.recommendations.length === 0) {
return <EmptyState icon={Globe} title="No EDHREC data" description="No recommendations found for this commander on EDHREC." />
}
const grouped = groupByRole(data.recommendations)
const roleOrder = ['Ramp', 'Draw', 'Removal', 'BoardWipe', 'Tutor', 'Protection', 'Finisher', 'Recursion', 'Other']
const sortedRoles = Object.keys(grouped).sort((a, b) => {
const ia = roleOrder.indexOf(a)
const ib = roleOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
const ownedCount = data.recommendations.filter(r => r.owned).length
const missingCount = data.recommendations.length - ownedCount
return (
<div className="space-y-4">
{/* Header bar */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<Sparkles className="w-4 h-4 text-accent-blue" />
<span className="text-text-primary font-medium">{data.commander}</span>
<span className="text-text-tertiary">via {data.source}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green">{ownedCount} owned</span>
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-tertiary">{missingCount} missing</span>
</div>
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={onlyOwned}
onChange={e => setOnlyOwned(e.target.checked)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5"
/>
Show only owned
</label>
</div>
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectOwned} className="text-xs text-accent-blue hover:underline">Owned</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedRoles.map(role => {
const cards = grouped[role]
const isCollapsed = collapsedGroups.has(role)
return (
<div key={role} className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
{/* Group header */}
<button
onClick={() => toggleGroup(role)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<RoleBadge role={role} />
<span className="text-xs text-text-tertiary">({cards.length})</span>
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div
key={rec.name}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group relative"
onMouseEnter={() => setHoveredCard(rec)}
onMouseLeave={() => setHoveredCard(null)}
>
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary truncate">{rec.type_line}</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Owned badge */}
<span className={'text-xs px-2 py-0.5 rounded flex-shrink-0 ' + (rec.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary')}>
{rec.owned ? (rec.owned_qty + 'x owned') : 'Missing'}
</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
{/* Hover preview */}
{hoveredCard && hoveredCard.image_url && (
<div className="fixed top-20 right-8 z-50 pointer-events-none animate-fade-in">
<img
src={hoveredCard.image_url}
alt={hoveredCard.name}
className="w-[244px] rounded-xl shadow-2xl shadow-black/60 border border-border-primary"
/>
</div>
)}
</div>
)
}
// ── Collection Recommendations Sub-tab ──────────────────────
function CollectionRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<CollectionRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getCollectionRecs(deckId)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load collection recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
const all = new Set<string>()
Object.values(data.grouped).flat().forEach(c => all.add(c.name))
setSelectedCards(all)
}
function selectNone() { setSelectedCards(new Set()) }
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from groupings
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => c.name !== name)
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - 1 })
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => !selectedCards.has(c.name))
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - selectedCards.size })
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.total === 0) {
return <EmptyState icon={Library} title="No collection matches" description="No cards in your collection match this deck's needs." />
}
const typeOrder = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedTypes = Object.keys(data.grouped).sort((a, b) => {
const ia = typeOrder.indexOf(a)
const ib = typeOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
return (
<div className="space-y-4">
{/* Shortfall alert */}
{data.shortfall_types.length > 0 && (
<div className="flex items-start gap-3 bg-accent-blue/8 border border-accent-blue/20 rounded-xl px-4 py-3">
<Lightbulb className="w-4 h-4 text-accent-blue mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs font-medium text-accent-blue">Deck needs more:</p>
<div className="flex flex-wrap gap-1.5 mt-1">
{data.shortfall_types.map(t => (
<span key={t} className="text-[10px] font-medium px-2 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">{t}</span>
))}
</div>
</div>
</div>
)}
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedTypes.map(type => {
const cards = data.grouped[type]
const isCollapsed = collapsedGroups.has(type)
const isShortfall = data.shortfall_types.includes(type)
return (
<div key={type} className={'bg-bg-secondary rounded-xl border overflow-hidden ' + (isShortfall ? 'border-accent-blue/30' : 'border-border-primary')}>
{/* Group header */}
<button
onClick={() => toggleGroup(type)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-primary uppercase tracking-wider">{type}</span>
<span className="text-xs text-text-tertiary">({cards.length})</span>
{isShortfall && <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">Shortfall</span>}
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div key={rec.scryfall_id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group">
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary">{rec.cmc} CMC</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Score */}
<span className="text-xs text-accent-teal font-medium flex-shrink-0">Score: {rec.score}</span>
{/* Owned qty */}
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green flex-shrink-0">{rec.owned_qty}x</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)
}
// ── Deck Detail View ──────────────────────────────────────────
function DeckDetail({ deck, onBack }: { deck: Deck; onBack: () => void }) {
const [cards, setCards] = useState<DeckCard[]>([])
const [analysis, setAnalysis] = useState<DeckAnalysis | null>(null)
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<'cards' | 'analysis' | 'recs'>('cards')
const [recSource, setRecSource] = useState<'edhrec' | 'collection'>('edhrec')
const [addingCard, setAddingCard] = useState('')
const [bulkText, setBulkText] = useState('')
const [showBulk, setShowBulk] = useState(false)
async function fetchAll() {
setLoading(true)
try {
const [c, a] = await Promise.all([
decksApi.getDeckCards(deck.id),
decksApi.getDeckAnalysis(deck.id),
])
setCards(c)
setAnalysis(a)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchAll() }, [deck.id])
async function handleAddCard() {
if (!addingCard.trim()) return
try {
await decksApi.addCardToDeck(deck.id, { name: addingCard.trim() })
setAddingCard('')
fetchAll()
} catch { /* ignore */ }
}
async function handleBulkAdd() {
if (!bulkText.trim()) return
const cardLines = bulkText.split('\n').filter(l => l.trim())
const cardParsed = cardLines.map(l => {
const match = l.match(/^(\d+)x?\s+(.+)$/)
return match ? { name: match[2].trim(), quantity: Number(match[1]) } : { name: l.trim() }
})
try {
await decksApi.bulkAddCards(deck.id, cardParsed)
setBulkText('')
setShowBulk(false)
fetchAll()
} catch { /* ignore */ }
}
async function handleExportToSim() {
try {
await decksApi.exportDeckToSim(deck.id)
} catch { /* ignore */ }
}
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={onBack} className="p-2 rounded-lg hover:bg-bg-hover text-text-secondary transition-colors">
<ChevronRight className="w-5 h-5 rotate-180" />
</button>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{deck.name}</h2>
<div className="flex items-center gap-3 mt-1">
<span className="text-sm text-text-secondary">{deck.commander}</span>
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
<span className="text-xs text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
<button onClick={handleExportToSim}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export to Sim
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-border-primary">
{(['cards', 'analysis', 'recs'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors capitalize ${
tab === t
? 'border-accent-blue text-accent-blue'
: 'border-transparent text-text-tertiary hover:text-text-secondary'
}`}
>
{t === 'recs' ? 'Recommendations' : t}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : tab === 'cards' ? (
<div className="space-y-4">
{/* Add card */}
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Add card by name..."
value={addingCard}
onChange={e => setAddingCard(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCard()}
className="flex-1 px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleAddCard}
className="px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Add
</button>
<button onClick={() => setShowBulk(!showBulk)}
className="px-3 py-2.5 bg-bg-tertiary text-text-secondary text-sm rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Layers className="w-4 h-4" />
</button>
</div>
{showBulk && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 animate-fade-in space-y-3">
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n2x Counterspell"}
value={bulkText}
onChange={e => setBulkText(e.target.value)}
rows={6}
className="w-full px-4 py-3 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
<button onClick={handleBulkAdd}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Bulk Add
</button>
</div>
)}
<DeckCardList deckId={deck.id} cards={cards} onUpdate={fetchAll} />
</div>
) : tab === 'analysis' && analysis ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Stats */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-accent-blue" />
Deck Stats
</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Cards', value: analysis.card_count },
{ label: 'Lands', value: analysis.land_count },
{ label: 'Creatures', value: analysis.creature_count },
{ label: 'Non-Creature', value: analysis.noncreature_count },
{ label: 'Avg CMC', value: analysis.avg_cmc.toFixed(2) },
{ label: 'Owned', value: `${analysis.owned_count} / ${analysis.card_count}` },
].map(stat => (
<div key={stat.label} className="px-3 py-2 rounded-lg bg-bg-tertiary">
<p className="text-xs text-text-tertiary">{stat.label}</p>
<p className="text-lg font-bold text-text-primary">{stat.value}</p>
</div>
))}
</div>
</div>
{/* Mana Curve */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary">Mana Curve</h3>
<ManaCurveChart analysis={analysis} />
</div>
{/* Type Distribution */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3 md:col-span-2">
<h3 className="text-sm font-semibold text-text-primary">Type Distribution</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(analysis.type_distribution).map(([type, count]) => (
<span key={type} className="px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs text-text-secondary">
{type}: <span className="font-semibold text-text-primary">{count}</span>
</span>
))}
</div>
</div>
</div>
) : tab === 'recs' ? (
<div className="space-y-4">
{/* Sub-tab toggle */}
<div className="flex items-center gap-1 bg-bg-tertiary rounded-lg p-1 w-fit">
<button
onClick={() => setRecSource('edhrec')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'edhrec'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Globe className="w-3.5 h-3.5" />
EDHREC
</button>
<button
onClick={() => setRecSource('collection')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'collection'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Library className="w-3.5 h-3.5" />
From Collection
</button>
</div>
{/* Sub-tab content */}
{recSource === 'edhrec' ? (
<EdhRecTab deckId={deck.id} onAddedCards={fetchAll} />
) : (
<CollectionRecTab deckId={deck.id} onAddedCards={fetchAll} />
)}
</div>
) : null}
</div>
)
}
// ── Main Decks Page ───────────────────────────────────────────
export function DecksPage() {
const [decks, setDecks] = useState<Deck[]>([])
const [loading, setLoading] = useState(true)
const [selectedDeck, setSelectedDeck] = useState<Deck | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [newDeckName, setNewDeckName] = useState('')
const [newCommander, setNewCommander] = useState('')
const [creating, setCreating] = useState(false)
async function fetchDecks() {
setLoading(true)
try {
const d = await decksApi.listDecks()
setDecks(d)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchDecks() }, [])
async function handleCreate() {
if (!newDeckName.trim()) return
setCreating(true)
try {
const deck = await decksApi.createDeck({ name: newDeckName.trim(), commander: newCommander.trim() || undefined })
setNewDeckName('')
setNewCommander('')
setShowCreate(false)
setSelectedDeck(deck)
fetchDecks()
} catch { /* ignore */ }
setCreating(false)
}
async function handleDelete(deckId: number) {
try {
await decksApi.deleteDeck(deckId)
fetchDecks()
} catch { /* ignore */ }
}
if (selectedDeck) {
return (
<div className="p-6">
<DeckDetail deck={selectedDeck} onBack={() => { setSelectedDeck(null); fetchDecks() }} />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Decks</h1>
<p className="text-sm text-text-secondary mt-0.5">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors"
>
<Plus className="w-4 h-4" />
New Deck
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 animate-fade-in space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">Create New Deck</h3>
<button onClick={() => setShowCreate(false)} className="text-text-tertiary hover:text-text-secondary">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Deck name"
value={newDeckName}
onChange={e => setNewDeckName(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Commander (optional)"
value={newCommander}
onChange={e => setNewCommander(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<button onClick={handleCreate} disabled={creating}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{creating ? <Spinner size="sm" /> : 'Create'}
</button>
</div>
)}
{/* Deck list */}
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : decks.length === 0 ? (
<EmptyState icon={Layers} title="No decks yet" description="Create your first deck to get started." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{decks.map(deck => (
<div
key={deck.id}
className="bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary p-5 cursor-pointer transition-all group"
onClick={() => setSelectedDeck(deck)}
>
<div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-text-primary truncate group-hover:text-accent-blue transition-colors">{deck.name}</h3>
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
</div>
<button
onClick={e => { e.stopPropagation(); handleDelete(deck.id) }}
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-tertiary hover:text-accent-red transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
</div>
<span className="text-xs font-medium text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,284 +0,0 @@
import { useState, useEffect } from 'react'
import {
Gamepad2, Play, Wifi, WifiOff, Settings,
AlertCircle, Trophy, Clock, Brain
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { simApi, labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { SimStatus, SimResult, DeepSeekStatus, LabDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
export function SimulatorPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [deckA, setDeckA] = useState('')
const [deckB, setDeckB] = useState('')
const [games, setGames] = useState(50)
const [useDeepSeek, setUseDeepSeek] = useState(false)
const [deepSeekDeck, setDeepSeekDeck] = useState('')
const [dsStatus, setDsStatus] = useState<DeepSeekStatus | null>(null)
const [running, setRunning] = useState(false)
const [simId, setSimId] = useState<string | null>(null)
const [simStatus, setSimStatus] = useState<SimStatus | null>(null)
const [result, setResult] = useState<SimResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
// DS config
const [showDsConfig, setShowDsConfig] = useState(false)
const [dsModel, setDsModel] = useState('')
const [dsUrl, setDsUrl] = useState('')
useEffect(() => {
labApi.getLabDecks().then(setDecks).catch(() => {})
simApi.getDeepSeekStatus().then(s => {
setDsStatus(s)
if (s.model) setDsModel(s.model)
if (s.base_url) setDsUrl(s.base_url)
}).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
if (!simId) return
try {
const s = await simApi.getSimStatus(simId)
setSimStatus(s)
if (!s.running) {
const r = await simApi.getSimResult(simId)
setResult(r)
setRunning(false)
}
} catch { /* ignore */ }
}, 2000, running)
async function handleStart() {
if (!deckA || !deckB) { setError('Select both decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
let res
if (useDeepSeek && deepSeekDeck) {
res = await simApi.runDeepSeekSim({ deck_a: deckA, deck_b: deckB, games, deepseek_deck: deepSeekDeck })
} else {
res = await simApi.runSim({ deck_a: deckA, deck_b: deckB, games })
}
setSimId(res.sim_id)
setRunning(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleDsConnect() {
try {
const s = await simApi.connectDeepSeek()
setDsStatus(s)
} catch { /* ignore */ }
}
async function handleDsConfigure() {
try {
const s = await simApi.configureDeepSeek({
model: dsModel || undefined,
base_url: dsUrl || undefined,
})
setDsStatus(s)
setShowDsConfig(false)
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Gamepad2 className="w-6 h-6 text-accent-purple" />
Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run head-to-head deck simulations</p>
</div>
{/* DeepSeek status */}
<div className="flex items-center gap-3">
<StatusBadge
variant={dsStatus?.connected ? 'success' : 'warning'}
label={dsStatus?.connected ? `DeepSeek: ${dsStatus.model}` : 'DeepSeek: Disconnected'}
/>
<button onClick={handleDsConnect}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
{dsStatus?.connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{dsStatus?.connected ? 'Reconnect' : 'Connect'}
</button>
<button onClick={() => setShowDsConfig(!showDsConfig)}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
Configure
</button>
</div>
{showDsConfig && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 max-w-md animate-fade-in space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Brain className="w-4 h-4 text-accent-purple" />
DeepSeek Configuration
</h3>
<input
type="text"
placeholder="Model name"
value={dsModel}
onChange={e => setDsModel(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Base URL (e.g. http://192.168.0.240:11434)"
value={dsUrl}
onChange={e => setDsUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleDsConfigure}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Save
</button>
</div>
)}
{/* Sim config */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck A</label>
<select
value={deckA}
onChange={e => setDeckA(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck B</label>
<select
value={deckB}
onChange={e => setDeckB(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
</div>
<div className="flex items-center gap-6">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number" min={1} max={1000}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-24 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-5">
<input type="checkbox" checked={useDeepSeek} onChange={e => setUseDeepSeek(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-purple focus:ring-accent-purple/30" />
<span className="text-sm text-text-secondary">Use DeepSeek AI pilot</span>
</label>
{useDeepSeek && (
<div className="mt-5">
<select
value={deepSeekDeck}
onChange={e => setDeepSeekDeck(e.target.value)}
className="px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">AI pilots which deck?</option>
{[deckA, deckB].filter(Boolean).map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
{running ? (
<div className="flex items-center gap-3">
<Spinner size="md" className="text-accent-blue" />
<div>
<p className="text-sm text-text-primary">Simulation running...</p>
{simStatus && (
<p className="text-xs text-text-tertiary">
{simStatus.games_completed ?? 0} / {simStatus.total_games ?? games} games
</p>
)}
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || !deckA || !deckB}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-semibold rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Run Simulation
</button>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{(result.duration_ms / 1000).toFixed(1)}s · {result.total_games} games
</span>
</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={result.decks.map(d => ({ name: d.name, win_rate: Math.round(d.win_rate * 100) }))} barSize={40}>
<XAxis dataKey="name" tick={{ fill: '#9ba1b8', fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']} />
<Bar dataKey="win_rate" radius={[4, 4, 0, 0]}>
{result.decks.map((d, i) => (
<Cell key={i} fill={d.win_rate === Math.max(...result.decks.map(x => x.win_rate)) ? '#4ade80' : '#4f6ef7'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-4">
{result.decks.map(d => (
<div key={d.name} className="px-4 py-3 rounded-lg bg-bg-tertiary">
<p className="text-sm font-semibold text-text-primary">{d.name}</p>
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<p className="text-[10px] text-text-tertiary uppercase">Wins</p>
<p className="text-lg font-bold text-accent-green">{d.wins}</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Win Rate</p>
<p className="text-lg font-bold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Avg Turn</p>
<p className="text-lg font-bold text-text-primary">{d.avg_turn.toFixed(1)}</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -1,822 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import {
Brain, Play, RotateCw, Database, Trophy, AlertCircle,
Activity, Zap, Swords, FileText, Cpu, CheckCircle,
ChevronDown, ChevronUp, Settings
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { trainingApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, MLModelInfo
} from '../types'
// ── Progress Bar ──────────────────────────────────────────────
function ProgressBar({ value, max, label, color = 'bg-accent-blue' }: { value: number; max: number; label: string; color?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-secondary">{label}</span>
<span className="text-text-tertiary">{value} / {max} ({pct.toFixed(0)}%)</span>
</div>
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div className={'h-full rounded-full transition-all duration-500 ' + color} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
// ── Phase Badge ──────────────────────────────────────────────
const PHASE_STYLES: Record<string, string> = {
idle: 'bg-bg-tertiary text-text-tertiary',
starting: 'bg-accent-blue/15 text-accent-blue animate-pulse',
building: 'bg-accent-purple/15 text-accent-purple animate-pulse',
training: 'bg-accent-blue/15 text-accent-blue animate-pulse',
evaluating: 'bg-accent-teal/15 text-accent-teal animate-pulse',
running: 'bg-accent-amber/15 text-accent-amber animate-pulse',
done: 'bg-accent-green/15 text-accent-green',
error: 'bg-accent-red/15 text-accent-red',
}
function PhaseBadge({ phase }: { phase: string }) {
const cls = PHASE_STYLES[phase] || PHASE_STYLES.idle
return (
<span className={'text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ' + cls}>
{phase}
</span>
)
}
// ── Metric Card ──────────────────────────────────────────────
function MetricCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary">{label}</p>
<p className={'text-sm font-bold ' + (accent || 'text-text-primary')}>{value}</p>
</div>
)
}
// ── Collapsible Section ─────────────────────────────────────
function Section({ title, icon, children, defaultOpen = true, color = 'text-accent-blue' }: {
title: string; icon: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; color?: string
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-bg-hover transition-colors"
>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<span className={color}>{icon}</span>
{title}
</h3>
{open ? <ChevronUp className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{open && <div className="px-5 pb-5 space-y-4 border-t border-border-primary pt-4">{children}</div>}
</div>
)
}
// ══════════════════════════════════════════════════════════════
// Main Training Page
// ══════════════════════════════════════════════════════════════
export function TrainingPage() {
// Status states
const [mlStatus, setMlStatus] = useState<MLStatus | null>(null)
const [dataStatus, setDataStatus] = useState<MLDataStatus | null>(null)
const [modelInfo, setModelInfo] = useState<MLModelInfo | null>(null)
const [trainStatus, setTrainStatus] = useState<MLTrainStatus | null>(null)
const [ppoStatus, setPpoStatus] = useState<PPOTrainStatus | null>(null)
const [tournStatus, setTournStatus] = useState<TournamentStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Supervised training config
const [epochs, setEpochs] = useState(50)
const [lr, setLr] = useState(0.001)
const [batchSize, setBatchSize] = useState(256)
const [patience, setPatience] = useState(10)
const [rebuildDataset, setRebuildDataset] = useState(true)
// PPO config
const [ppoIterations, setPpoIterations] = useState(100)
const [ppoEpisodes, setPpoEpisodes] = useState(64)
const [ppoEpochs, setPpoEpochs] = useState(4)
const [ppoBatchSize, setPpoBatchSize] = useState(256)
const [ppoLr, setPpoLr] = useState(0.0003)
const [ppoClipEps, setPpoClipEps] = useState(0.2)
const [ppoEntropy, setPpoEntropy] = useState(0.01)
const [ppoOpponent, setPpoOpponent] = useState('heuristic')
const [ppoPlaystyle, _setPpoPlaystyle] = useState('midrange') // eslint-disable-line
// Tournament config
const [tournEpisodes, setTournEpisodes] = useState(50)
const [tournPlaystyle, setTournPlaystyle] = useState('midrange')
// Action states
const [startingTrain, setStartingTrain] = useState(false)
const [startingPpo, setStartingPpo] = useState(false)
const [startingTourn, setStartingTourn] = useState(false)
const [reloading, setReloading] = useState(false)
const [showSupAdvanced, setShowSupAdvanced] = useState(false)
const [showPpoAdvanced, setShowPpoAdvanced] = useState(false)
// ── Initial Load ──────────────────────────────────────────
const loadAll = useCallback(async () => {
try {
const [ml, data, model, train, ppo, tourn] = await Promise.all([
trainingApi.getMLStatus().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
trainingApi.getModelInfo().catch(() => null),
trainingApi.getTrainStatus().catch(() => null),
trainingApi.getPPOStatus().catch(() => null),
trainingApi.getTournamentStatus().catch(() => null),
])
if (ml) setMlStatus(ml)
if (data) setDataStatus(data)
if (model) setModelInfo(model)
if (train) setTrainStatus(train)
if (ppo) setPpoStatus(ppo)
if (tourn) setTournStatus(tourn)
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadAll() }, [loadAll])
// ── Polling ───────────────────────────────────────────────
const isTraining = trainStatus?.running || false
const isPpo = ppoStatus?.running || false
const isTourn = tournStatus?.running || false
const anyRunning = isTraining || isPpo || isTourn
usePolling(async () => {
try {
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
if (!s.running && s.phase === 'done') {
// Refresh model info + data when training completes
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isTraining)
usePolling(async () => {
try {
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
if (!s.running && s.phase === 'done') {
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isPpo)
usePolling(async () => {
try {
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch { /* ignore */ }
}, 2000, isTourn)
// ── Actions ───────────────────────────────────────────────
async function handleToggleLogging() {
try {
const newState = !(mlStatus?.ml_logging_enabled ?? false)
await trainingApi.toggleML(newState)
const ml = await trainingApi.getMLStatus()
setMlStatus(ml)
} catch { /* ignore */ }
}
async function handleReloadModel() {
setReloading(true)
try {
const res = await trainingApi.reloadModel()
setModelInfo(res.status)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to reload model')
}
setReloading(false)
}
async function handleStartTrain() {
setStartingTrain(true)
setError(null)
try {
await trainingApi.startTraining({ epochs, lr, batchSize, patience, rebuildDataset })
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start training')
}
setStartingTrain(false)
}
async function handleStartPpo() {
setStartingPpo(true)
setError(null)
try {
await trainingApi.startPPOTraining({
iterations: ppoIterations,
episodesPerIter: ppoEpisodes,
ppoEpochs,
batchSize: ppoBatchSize,
lr: ppoLr,
clipEpsilon: ppoClipEps,
entropyCoeff: ppoEntropy,
opponent: ppoOpponent,
playstyle: ppoPlaystyle,
})
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start PPO training')
}
setStartingPpo(false)
}
async function handleStartTournament() {
setStartingTourn(true)
setError(null)
try {
await trainingApi.startTournament({ episodes: tournEpisodes, playstyle: tournPlaystyle })
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start tournament')
}
setStartingTourn(false)
}
// ── Render ────────────────────────────────────────────────
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
const trainDataset = dataStatus?.datasets?.['train']
const hasDecisions = (mlStatus?.total_decisions ?? 0) > 0
const hasModel = modelInfo?.loaded === true
return (
<div className="p-6 space-y-5">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Brain className="w-6 h-6 text-accent-green" />
ML Training Pipeline
</h1>
<p className="text-sm text-text-secondary mt-0.5">Collect data, train models, and evaluate performance</p>
</div>
{/* Error banner */}
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-accent-red/10 border border-accent-red/30 rounded-lg text-sm text-accent-red">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button onClick={() => setError(null)} className="text-accent-red/60 hover:text-accent-red text-xs">dismiss</button>
</div>
)}
{/* Pipeline overview bar */}
<div className="flex flex-wrap items-center gap-3 bg-bg-secondary rounded-xl border border-border-primary px-5 py-3">
<div className="flex items-center gap-2">
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
<span className="text-xs text-text-secondary">Logging {mlStatus?.ml_logging_enabled ? 'On' : 'Off'}</span>
</div>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{mlStatus?.total_decisions ?? 0} decisions</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{trainDataset ? `${trainDataset.samples.toLocaleString()} training samples` : 'No dataset'}</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{(dataStatus?.checkpoints?.length ?? 0)} checkpoints</span>
<span className="text-border-primary">|</span>
<StatusBadge
variant={hasModel ? 'success' : 'neutral'}
label={hasModel ? `Model on ${modelInfo?.device ?? '?'}` : 'No model'}
/>
{anyRunning && (
<>
<span className="text-border-primary">|</span>
<span className="text-xs font-medium text-accent-blue animate-pulse">Pipeline running...</span>
</>
)}
</div>
{/* ═══ Phase 1: Data Collection ═══ */}
<Section title="Phase 1 — Data Collection" icon={<FileText className="w-4 h-4" />} color="text-accent-teal">
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">
Run batch simulations with ML logging enabled to collect decision data.
</p>
<button
onClick={handleToggleLogging}
className={'flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ' +
(mlStatus?.ml_logging_enabled
? 'bg-accent-green/15 text-accent-green border-accent-green/30 hover:bg-accent-green/25'
: 'bg-bg-tertiary text-text-secondary border-border-primary hover:bg-bg-hover'
)}
>
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
{mlStatus?.ml_logging_enabled ? 'Logging Enabled' : 'Enable Logging'}
</button>
</div>
{/* Decision files */}
{(dataStatus?.decisionFiles?.length ?? 0) > 0 ? (
<div className="space-y-1">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Decision Files</h4>
<div className="max-h-36 overflow-y-auto space-y-1">
{dataStatus!.decisionFiles.map(f => (
<div key={f.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{f.name}</span>
<div className="flex items-center gap-3 text-text-tertiary">
<span>{f.decisions.toLocaleString()} decisions</span>
<span>{(f.size / 1024).toFixed(0)} KB</span>
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-xs text-text-tertiary italic">No decision files yet. Run batch sims with logging enabled.</div>
)}
</Section>
{/* ═══ Phase 2: Dataset & Checkpoints ═══ */}
<Section title="Phase 2 — Dataset & Model Artifacts" icon={<Database className="w-4 h-4" />} color="text-accent-purple">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Dataset splits */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Dataset Splits</h4>
{Object.keys(dataStatus?.datasets ?? {}).length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{(['train', 'val', 'test'] as const).map(split => {
const ds = dataStatus?.datasets?.[split]
return (
<div key={split} className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary uppercase">{split}</p>
{ds ? (
<>
<p className="text-sm font-bold text-text-primary">{ds.samples.toLocaleString()}</p>
<p className="text-[10px] text-text-tertiary">{ds.features} features</p>
</>
) : (
<p className="text-xs text-text-tertiary italic">Not built</p>
)}
</div>
)
})}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No dataset built yet. Training will build it automatically.</p>
)}
</div>
{/* Checkpoints */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Checkpoints</h4>
{(dataStatus?.checkpoints?.length ?? 0) > 0 ? (
<div className="max-h-28 overflow-y-auto space-y-1">
{dataStatus!.checkpoints.map(ckpt => (
<div key={ckpt.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{ckpt.name}</span>
<span className="text-text-tertiary">{(ckpt.size / 1024 / 1024).toFixed(1)} MB</span>
</div>
))}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No checkpoints yet. Train a model to generate them.</p>
)}
</div>
</div>
{/* Model info + reload */}
<div className="flex items-center justify-between pt-2 border-t border-border-primary">
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-text-tertiary" />
<div className="text-xs">
{hasModel ? (
<span className="text-accent-green font-medium">
Model loaded on {modelInfo?.device} | {modelInfo?.input_dim} features | {modelInfo?.num_actions} actions
</span>
) : (
<span className="text-text-tertiary">No model loaded{modelInfo?.error ? ` — ${modelInfo.error}` : ''}</span>
)}
</div>
</div>
<button
onClick={handleReloadModel}
disabled={reloading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50"
>
{reloading ? <Spinner size="sm" /> : <RotateCw className="w-3 h-3" />}
Reload Model
</button>
</div>
{/* Eval results */}
{dataStatus?.evalResults && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
Latest Evaluation
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(dataStatus.evalResults).map(([k, v]) => (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={typeof v === 'number' ? (k.includes('accuracy') || k.includes('rate') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)) : String(v)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined}
/>
))}
</div>
</div>
)}
</Section>
{/* ═══ Phase 3: Supervised Training ═══ */}
<Section title="Phase 3 — Supervised Training" icon={<Activity className="w-4 h-4" />}>
{/* Config */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Epochs</label>
<input type="number" min={1} max={1000} value={epochs} onChange={e => setEpochs(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.00001} max={1} step={0.0001} value={lr} onChange={e => setLr(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
{/* Advanced toggle */}
<button onClick={() => setShowSupAdvanced(!showSupAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showSupAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showSupAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Early Stop Patience</label>
<input type="number" min={1} max={100} value={patience} onChange={e => setPatience(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input type="checkbox" checked={rebuildDataset} onChange={e => setRebuildDataset(e.target.checked)}
disabled={isTraining}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5" />
Rebuild dataset
</label>
</div>
</div>
)}
</div>
{/* Training status / start button */}
{isTraining || (trainStatus?.phase && trainStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PhaseBadge phase={trainStatus!.phase} />
<span className="text-xs text-text-secondary">{trainStatus!.message}</span>
</div>
{trainStatus!.started_at && (
<span className="text-[10px] text-text-tertiary">Started: {new Date(trainStatus!.started_at).toLocaleTimeString()}</span>
)}
</div>
{/* Progress bar — only in training phase */}
{(trainStatus!.phase === 'training' || trainStatus!.phase === 'done') && trainStatus!.total_epochs > 0 && (
<ProgressBar
value={trainStatus!.current_epoch}
max={trainStatus!.total_epochs}
label="Epoch progress"
/>
)}
{/* Live metrics during training */}
{trainStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(trainStatus!.metrics).map(([k, v]) => {
if (typeof v !== 'number') return null
const isAcc = k.includes('accuracy') || k.includes('acc')
const isLoss = k.includes('loss')
return (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={isAcc ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={isAcc ? 'text-accent-green' : isLoss ? 'text-accent-amber' : undefined}
/>
)
})}
</div>
)}
{/* Final result */}
{trainStatus!.phase === 'done' && trainStatus!.result && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-accent-green flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5" /> Training Complete
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<MetricCard label="Device" value={trainStatus!.result.device} />
<MetricCard label="Checkpoint" value={trainStatus!.result.checkpoint.split('/').pop() || '—'} />
{trainStatus!.result.evaluation && Object.entries(trainStatus!.result.evaluation).map(([k, v]) => (
typeof v === 'number' ? (
<MetricCard key={k} label={k.replace(/_/g, ' ')}
value={k.includes('accuracy') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined} />
) : null
))}
</div>
</div>
)}
{/* Error state */}
{trainStatus!.phase === 'error' && trainStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{trainStatus!.error}</span>
</div>
)}
</div>
) : (
<div className="flex items-center gap-3">
<button
onClick={handleStartTrain}
disabled={startingTrain || !hasDecisions || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue/15 text-accent-blue text-sm font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTrain ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Training
</button>
{!hasDecisions && (
<span className="text-xs text-text-tertiary italic">Enable logging and run batch sims first to generate decision data.</span>
)}
</div>
)}
</Section>
{/* ═══ Phase 4: PPO Reinforcement Learning ═══ */}
<Section title="Phase 4 — PPO Reinforcement Learning" icon={<Zap className="w-4 h-4" />} color="text-accent-purple" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Fine-tune the supervised model using PPO self-play against an opponent policy.
</p>
{/* Config */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Iterations</label>
<input type="number" min={1} max={1000} value={ppoIterations} onChange={e => setPpoIterations(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Opponent</label>
<select value={ppoOpponent} onChange={e => setPpoOpponent(e.target.value)}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
<option value="self">Self-Play</option>
</select>
</div>
</div>
<button onClick={() => setShowPpoAdvanced(!showPpoAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showPpoAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showPpoAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes / Iter</label>
<input type="number" min={1} max={512} value={ppoEpisodes} onChange={e => setPpoEpisodes(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">PPO Epochs</label>
<input type="number" min={1} max={20} value={ppoEpochs} onChange={e => setPpoEpochs(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={ppoBatchSize} onChange={e => setPpoBatchSize(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.000001} max={0.01} step={0.0001} value={ppoLr} onChange={e => setPpoLr(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Clip Epsilon</label>
<input type="number" min={0.05} max={0.5} step={0.05} value={ppoClipEps} onChange={e => setPpoClipEps(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Entropy Coeff</label>
<input type="number" min={0} max={0.1} step={0.005} value={ppoEntropy} onChange={e => setPpoEntropy(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
)}
{/* PPO running status */}
{isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={ppoStatus!.phase} />
<span className="text-xs text-text-secondary">{ppoStatus!.message}</span>
</div>
{(ppoStatus!.phase === 'training' || ppoStatus!.phase === 'done') && ppoStatus!.total_iterations > 0 && (
<ProgressBar
value={ppoStatus!.iteration}
max={ppoStatus!.total_iterations}
label="PPO iteration"
color="bg-accent-purple"
/>
)}
{ppoStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{ppoStatus!.metrics.win_rate != null && (
<MetricCard label="Win Rate" value={`${(ppoStatus!.metrics.win_rate * 100).toFixed(1)}%`} accent="text-accent-green" />
)}
{ppoStatus!.metrics.avg_reward != null && (
<MetricCard label="Avg Reward" value={ppoStatus!.metrics.avg_reward.toFixed(3)} accent="text-accent-amber" />
)}
{ppoStatus!.metrics.policy_loss != null && (
<MetricCard label="Policy Loss" value={ppoStatus!.metrics.policy_loss.toFixed(4)} />
)}
{ppoStatus!.metrics.entropy != null && (
<MetricCard label="Entropy" value={ppoStatus!.metrics.entropy.toFixed(4)} />
)}
</div>
)}
{ppoStatus!.phase === 'done' && ppoStatus!.result && (
<div className="flex items-center gap-2 pt-2 border-t border-border-primary">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
<span className="text-xs text-accent-green font-medium">
PPO Training Complete — Best Win Rate: {
typeof ppoStatus!.result.best_win_rate === 'number'
? `${(ppoStatus!.result.best_win_rate as number * 100).toFixed(1)}%`
: '—'
}
</span>
</div>
)}
{ppoStatus!.phase === 'error' && ppoStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{ppoStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartPpo}
disabled={startingPpo || isTraining || !hasModel}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-medium rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingPpo ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start PPO Training
</button>
)}
{!hasModel && !(isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle')) && (
<p className="text-xs text-text-tertiary italic">Train a supervised model first (Phase 3) before running PPO.</p>
)}
</Section>
{/* ═══ Phase 5: Tournament Evaluation ═══ */}
<Section title="Phase 5 — Tournament Evaluation" icon={<Swords className="w-4 h-4" />} color="text-accent-amber" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Pit the trained model against heuristic, random, and other policy variants.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes per Matchup</label>
<input type="number" min={10} max={1000} value={tournEpisodes} onChange={e => setTournEpisodes(Number(e.target.value))}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Playstyle</label>
<select value={tournPlaystyle} onChange={e => setTournPlaystyle(e.target.value)}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
<option value="control">Control</option>
<option value="combo">Combo</option>
</select>
</div>
</div>
{/* Tournament status */}
{isTourn || (tournStatus?.phase && tournStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={tournStatus!.phase} />
<span className="text-xs text-text-secondary">{tournStatus!.message}</span>
</div>
{tournStatus!.phase === 'error' && tournStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{tournStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartTournament}
disabled={startingTourn || isTraining || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-amber/15 text-accent-amber text-sm font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTourn ? <Spinner size="sm" /> : <Trophy className="w-4 h-4" />}
Start Tournament
</button>
)}
{/* Tournament results */}
{(tournStatus?.result?.players?.length ?? 0) > 0 && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<Trophy className="w-3.5 h-3.5 text-accent-amber" />
Leaderboard
</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium w-8">#</th>
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium">Policy</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Win Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{tournStatus!.result!.players
.sort((a, b) => b.win_rate - a.win_rate)
.map((p, i) => (
<tr key={p.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2 text-text-tertiary text-xs">
{i === 0 ? <Trophy className="w-3.5 h-3.5 text-accent-amber" /> : i + 1}
</td>
<td className="px-3 py-2">
<span className="text-text-primary font-medium capitalize">{p.name}</span>
</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.wins}</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.games}</td>
<td className={'px-3 py-2 text-right font-semibold ' + (i === 0 ? 'text-accent-green' : 'text-accent-blue')}>
{(p.win_rate * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
</div>
)
}
@@ -1,562 +0,0 @@
// ═══════════════════════════════════════════════════════════
// Commander AI Lab — TypeScript Type Definitions
// Derived from the FastAPI backend's Pydantic models + SQLite schema
// ═══════════════════════════════════════════════════════════
// ── Cards & Collection ──────────────────────────────────────
export interface CollectionCard {
id: number
name: string
scryfall_id: string
set_code: string
set_name: string
collector_number: string
rarity: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
color_identity: string[]
quantity: number
foil_quantity: number
condition: string
tcg_price: number | null
foil_price: number | null
image_url: string
edhrec_rank: number | null
keywords: string
added_date: string
updated_date: string
}
export interface CollectionFilters {
q?: string
type_line?: string
color?: string
rarity?: string
set_code?: string
cmc_min?: number
cmc_max?: number
price_min?: number
price_max?: number
owned_only?: boolean
sort_by?: string
sort_dir?: 'asc' | 'desc'
page?: number
page_size?: number
}
export interface CollectionStats {
total_cards: number
total_unique: number
total_value: number
sets_count: number
}
export interface SetInfo {
code: string
name: string
card_count?: number
}
// ── Scan ─────────────────────────────────────────────────────
export interface ScanResult {
matches: ScanMatch[]
image_url?: string
}
export interface ScanMatch {
name: string
set_code: string
set_name: string
collector_number: string
scryfall_id: string
confidence: number
image_url: string
type_line: string
oracle_text: string
mana_cost: string
tcg_price: number | null
}
// ── Decks ────────────────────────────────────────────────────
export interface Deck {
id: number
name: string
commander: string
commander_scryfall_id?: string
commander_image_url?: string
color_identity: string[]
format: string
card_count: number
total_price: number
created_date: string
updated_date: string
}
export interface DeckCard {
id: number
deck_id: number
name: string
scryfall_id: string
quantity: number
is_commander: boolean
category: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
tcg_price: number | null
image_url: string
owned_qty: number
set_code: string
}
export interface DeckAnalysis {
card_count: number
land_count: number
creature_count: number
noncreature_count: number
avg_cmc: number
mana_curve: Record<number, number>
color_distribution: Record<string, number>
type_distribution: Record<string, number>
total_price: number
owned_count: number
missing_count: number
}
export interface DeckRecommendation {
name: string
scryfall_id: string
type_line: string
mana_cost: string
oracle_text: string
image_url: string
tcg_price: number | null
reason: string
score: number
owned: boolean
}
// ── EDHREC Recommendations ──────────────────────────────────
export interface EdhRecCard {
name: string
type_line: string
role: string
roles: string[]
inclusion_pct: number | null
synergy_score: number | null
owned: boolean
owned_qty: number
scryfall_id: string
image_url: string | null
}
export interface EdhRecsResponse {
commander: string
source: string
total: number
recommendations: EdhRecCard[]
}
// ── Collection Recommendations ──────────────────────────────
export interface CollectionRecCard {
id: number
scryfall_id: string
name: string
type_line: string
card_type: string
cmc: number
color_identity: string[]
owned_qty: number
roles: string[]
score: number
image_url: string | null
}
export interface CollectionRecsResponse {
shortfall_types: string[]
role_filter: string[]
grouped: Record<string, CollectionRecCard[]>
total: number
}
// ── V3 Deck Generator ────────────────────────────────────────
export interface DeckGenV3Request {
commander_name: string
strategy?: string
target_bracket: number
budget_usd?: number
budget_mode?: 'total' | 'per_card'
omit_cards?: string[]
use_collection?: boolean
run_substitution?: boolean
model?: string
deck_name?: string
}
export interface DeckGenV3Status {
initialized: boolean
pplx_configured: boolean
model: string | null
embeddings_loaded: boolean
embedding_cards: number
error: string | null
}
export interface DeckGenV3Card {
name: string
quantity: number
category: string
type_line: string
mana_cost: string
cmc: number
role: string
reason: string
tcg_price: number | null
image_url: string
owned: boolean
owned_qty: number
scryfall_id: string
is_substitute: boolean
original_name: string | null
substitution_reason: string | null
similarity_score: number | null
}
export interface DeckGenV3Result {
commander: {
name: string
image_url: string
type_line: string
color_identity: string[]
mana_cost: string
oracle_text: string
}
cards: DeckGenV3Card[]
stats: DeckAnalysis
strategy: string
bracket: number
model: string
substitution?: {
owned: number
substituted: number
missing: number
}
}
// ── Simulation ───────────────────────────────────────────────
export interface SimConfig {
deck_names: string[]
num_games: number
threads: number
use_deepseek?: boolean
}
export interface SimStatus {
running: boolean
sim_id: string
progress?: number
games_completed?: number
total_games?: number
}
export interface SimResult {
sim_id: string
decks: SimDeckResult[]
total_games: number
duration_ms: number
timestamp: string
}
export interface SimDeckResult {
name: string
wins: number
losses: number
win_rate: number
avg_turn: number
}
// ── Batch Sim (Lab) ──────────────────────────────────────────
export interface LabDeck {
name: string
source: string
path: string
card_count: number
}
export interface LabStartRequest {
decks: string[]
games: number
threads?: number
use_deepseek?: boolean
deepseek_deck?: string
}
export interface LabStatus {
running: boolean
run_id: string
games_completed: number
total_games: number
current_decks: string[]
error: string | null
}
export interface LabResult {
run_id: string
decks: LabDeckResult[]
total_games: number
timestamp: string
}
export interface LabDeckResult {
name: string
wins: number
games: number
win_rate: number
avg_turns: number
avg_life_remaining: number
}
export interface LabHistoryEntry {
run_id: string
timestamp: string
decks: string[]
total_games: number
}
export interface PreconDeck {
name: string
filename: string
release_date?: string
set_name?: string
installed: boolean
}
// ── Coach ────────────────────────────────────────────────────
export interface CoachStatus {
llmConnected: boolean
llmModel: string | null
llmModels: string[]
embeddingsLoaded: boolean
embeddingCards: number
deckReportsAvailable: number
error: string | null
}
export interface CoachDeck {
deck_id: number
deck_name: string
commander: string
card_count: number
has_report: boolean
report_count: number
last_report_date: string | null
}
export interface CoachGoals {
targetPowerLevel?: number | null
metaFocus?: string | null
budget?: string | null
focusAreas?: string[]
}
export interface CoachMessage {
role: 'user' | 'assistant'
content: string
}
export interface CoachChatResponse {
content: string
model: string
prompt_tokens: number
completion_tokens: number
}
export interface CoachSession {
sessionId: string
deckId: string
timestamp: string
summary: string
suggestedCuts: CoachSuggestedCut[]
suggestedAdds: CoachSuggestedAdd[]
heuristicHints: string[]
manaBaseAdvice: string | null
rawTextExplanation: string
modelUsed: string
promptTokens: number
completionTokens: number
goals: CoachGoals | null
}
export interface CoachSuggestedCut {
cardName: string
reason: string
replacementOptions: string[]
currentImpactScore: number
}
export interface CoachSuggestedAdd {
cardName: string
role: string
reason: string
synergyWith: string[]
estimatedManaValue: number | null
}
export interface CoachSessionSummary {
sessionId: string
deckId: string
timestamp: string
summary: string
cutsCount: number
addsCount: number
}
export interface CoachApplyResult {
cuts: { name: string; status: string }[]
adds: { name: string; scryfall_id?: string; status: string }[]
errors: { name: string; error: string }[]
total_cuts: number
total_adds: number
}
export interface CoachCardLikeResult {
name: string
similarity: number
types: string
mana_value: number
mana_cost: string
text: string
owned_qty: number
image_url: string | null
tcg_price: number | null
}
// ── ML Training ──────────────────────────────────────────────
/** GET /api/ml/status */
export interface MLStatus {
ml_logging_enabled: boolean
training_files: { file: string; decisions: number; size_kb: number }[]
total_decisions: number
total_files: number
}
/** GET /api/ml/data/status */
export interface MLDataStatus {
decisionFiles: { name: string; size: number; decisions: number }[]
totalDecisions: number
datasets: Record<string, { samples: number; features: number; size: number }>
checkpoints: { name: string; size: number; modified: string }[]
evalResults: Record<string, unknown> | null
policyLoaded: boolean
}
/** GET /api/ml/train/status */
export interface MLTrainStatus {
running: boolean
progress: number
total_epochs: number
current_epoch: number
phase: 'idle' | 'starting' | 'building' | 'training' | 'evaluating' | 'done' | 'error'
message: string
metrics: Record<string, unknown> | null
result: {
training: Record<string, unknown>
evaluation: Record<string, unknown> | null
checkpoint: string
device: string
} | null
error: string | null
started_at: string | null
}
/** GET /api/ml/train/ppo/status */
export interface PPOTrainStatus {
running: boolean
iteration: number
total_iterations: number
phase: 'idle' | 'starting' | 'training' | 'done' | 'error'
message: string
metrics: {
avg_reward?: number
win_rate?: number
policy_loss?: number
value_loss?: number
entropy?: number
[key: string]: unknown
} | null
result: Record<string, unknown> | null
error: string | null
}
/** GET /api/ml/tournament/status */
export interface TournamentStatus {
running: boolean
phase: 'idle' | 'starting' | 'running' | 'done' | 'error'
message: string
result: TournamentResults | null
error: string | null
}
export interface TournamentResults {
players: TournamentPlayer[]
total_matches: number
matchups?: Record<string, unknown>
}
export interface TournamentPlayer {
name: string
wins: number
games: number
win_rate: number
}
/** GET /api/ml/model */
export interface MLModelInfo {
loaded: boolean
device?: string
input_dim?: number
num_actions?: number
checkpoint_path?: string
error?: string
torch_available?: boolean
}
// ── DeepSeek ─────────────────────────────────────────────────
export interface DeepSeekStatus {
connected: boolean
model: string | null
base_url: string
}
// ── Commander Search ─────────────────────────────────────────
export interface CommanderSearchResult {
name: string
type_line: string
color_identity: string[]
mana_cost: string
image_url: string
oracle_text: string
scryfall_id: string
in_collection: boolean
}
// ── Perplexity ───────────────────────────────────────────────
export interface PplxStatus {
configured: boolean
}
@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -1,28 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
recharts: ['recharts'],
react: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
})
-23
View File
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend-tmp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-5077
View File
File diff suppressed because it is too large. Load diff
-42
View File
@@ -1,42 +0,0 @@
{
"name": "commander-ai-lab-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.0.0",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
-48
View File
@@ -1,48 +0,0 @@
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
import LobbyPage from './pages/LobbyPage';
import RoomPage from './pages/RoomPage';
import GamePage from './pages/GamePage';
import SimulatorPage from './pages/SimulatorPage';
function Nav() {
const location = useLocation();
// Hide nav on active game pages
if (location.pathname.startsWith('/game/')) return null;
const links = [
{ to: '/', label: 'Lobby' },
{ to: '/simulator', label: 'Simulator' },
];
return (
<nav className="bg-bg-bar border-b border-border-zone px-4 py-2 flex items-center gap-4">
<span className="text-sm font-bold text-white mr-4">Commander AI Lab</span>
{links.map((l) => (
<Link
key={l.to}
to={l.to}
className={`text-sm px-2 py-1 rounded transition-colors ${
location.pathname === l.to
? 'text-white bg-accent-blue/20'
: 'text-text-muted hover:text-white'
}`}
>
{l.label}
</Link>
))}
</nav>
);
}
function App() {
return (
<BrowserRouter>
<Nav />
<Routes>
<Route path="/" element={<LobbyPage />} />
<Route path="/room/:roomId" element={<RoomPage />} />
<Route path="/game/:gameId" element={<GamePage />} />
<Route path="/simulator" element={<SimulatorPage />} />
</Routes>
</BrowserRouter>
);
}
export default App;
-35
View File
@@ -1,35 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import CardChip from '../components/CardChip';
import type { Card } from '../types/game';
function makeCard(overrides: Partial<Card> = {}): Card {
return {
name: 'Sol Ring',
type: 'Artifact',
tapped: false,
is_commander: false,
oracle: 'Tap: Add CC.',
cmc: 1,
...overrides,
};
}
describe('CardChip', () => {
it('applies opacity class when tapped', () => {
render(<CardChip card={makeCard({ tapped: true })} />);
const chip = screen.getByText(/Sol Ring/);
expect(chip.closest('span')).toHaveClass('opacity-55');
});
it('shows star for commander cards', () => {
render(<CardChip card={makeCard({ is_commander: true })} />);
expect(screen.getByText('★')).toBeInTheDocument();
});
it('has hover border class', () => {
render(<CardChip card={makeCard()} />);
const chip = screen.getByText(/Sol Ring/).closest('span');
expect(chip).toHaveClass('hover:border-[#ffdc32]');
});
});
-86
View File
@@ -1,86 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import GamePage from '../pages/GamePage';
import type { GameState, Card } from '../types/game';
const mockGameState: GameState = {
game_id: 'test-game',
turn: 1,
current_phase: 'main1',
active_seat: 0,
players: [
{
seat: 0, name: 'Alice', life: 40, eliminated: false,
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
},
{
seat: 1, name: 'Bob', life: 38, eliminated: false,
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 6,
},
{
seat: 2, name: 'Charlie', life: 40, eliminated: false,
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
},
{
seat: 3, name: 'Diana', life: 35, eliminated: false,
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 5,
},
],
stack: [],
game_over: false,
winner: null,
};
const mockHand: Card[] = [];
const mockSendMove = vi.fn();
const mockSendConcede = vi.fn();
vi.mock('../hooks/useGameSocket', () => ({
useGameSocket: () => ({
gameState: mockGameState,
narration: [],
thinking: new Set<number>(),
hand: mockHand,
gameOver: null,
status: 'Connected',
sendMove: mockSendMove,
sendConcede: mockSendConcede,
}),
}));
describe('GamePage integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders all 4 PlayerZone components', () => {
render(
<MemoryRouter initialEntries={['/game/test-game']}>
<Routes>
<Route path="/game/:gameId" element={<GamePage />} />
</Routes>
</MemoryRouter>,
);
// P0: Alice appears in both PhaseBar and PlayerZone, so use getAllByText
expect(screen.getAllByText(/P0: Alice/).length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText(/P1: Bob/).length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText(/P2: Charlie/).length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText(/P3: Diana/).length).toBeGreaterThanOrEqual(1);
});
it('renders PhaseBar with current phase', () => {
render(
<MemoryRouter initialEntries={['/game/test-game']}>
<Routes>
<Route path="/game/:gameId" element={<GamePage />} />
</Routes>
</MemoryRouter>,
);
// main1 should be highlighted
const main1Buttons = screen.getAllByText('main1');
expect(main1Buttons.length).toBeGreaterThan(0);
});
});
-66
View File
@@ -1,66 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import MoveBar from '../components/MoveBar';
describe('MoveBar', () => {
it('calls onPass when pass button is clicked', async () => {
const onPass = vi.fn();
render(
<MoveBar
hand={[]}
humanSeat={0}
onPass={onPass}
onConcede={vi.fn()}
onPlayCard={vi.fn()}
status=""
/>,
);
await userEvent.click(screen.getByText('Pass Priority'));
expect(onPass).toHaveBeenCalledOnce();
});
it('shows confirm dialog on concede', async () => {
const onConcede = vi.fn();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(
<MoveBar
hand={[]}
humanSeat={0}
onPass={vi.fn()}
onConcede={onConcede}
onPlayCard={vi.fn()}
status=""
/>,
);
await userEvent.click(screen.getByText('Concede'));
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to concede?');
expect(onConcede).toHaveBeenCalledOnce();
confirmSpy.mockRestore();
});
it('does not concede if confirm is cancelled', async () => {
const onConcede = vi.fn();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
render(
<MoveBar
hand={[]}
humanSeat={0}
onPass={vi.fn()}
onConcede={onConcede}
onPlayCard={vi.fn()}
status=""
/>,
);
await userEvent.click(screen.getByText('Concede'));
expect(onConcede).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});
@@ -1,38 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import NarrationPanel from '../components/NarrationPanel';
import type { NarrationLine } from '../types/game';
describe('NarrationPanel', () => {
it('renders only 120 lines when given more', () => {
const lines: NarrationLine[] = Array.from({ length: 150 }, (_, i) => ({
text: `Line ${i}`,
isSystem: false,
timestamp: Date.now(),
}));
render(<NarrationPanel lines={lines} thinkingSeats={new Set()} />);
// Should not render the first 30 lines (0-29)
expect(screen.queryByText('Line 0')).not.toBeInTheDocument();
expect(screen.queryByText('Line 29')).not.toBeInTheDocument();
// Should render lines 30-149
expect(screen.getByText('Line 30')).toBeInTheDocument();
expect(screen.getByText('Line 149')).toBeInTheDocument();
});
it('has a scroll container ref', () => {
const lines: NarrationLine[] = [
{ text: 'Hello world', isSystem: false, timestamp: Date.now() },
];
const { container } = render(
<NarrationPanel lines={lines} thinkingSeats={new Set()} />,
);
// The container div has overflow-y-auto
const panel = container.firstChild as HTMLElement;
expect(panel).toHaveClass('overflow-y-auto');
});
});
-38
View File
@@ -1,38 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import PhaseBar from '../components/PhaseBar';
import type { Player } from '../types/game';
const players: Player[] = [
{
seat: 0, name: 'Alice', life: 40, eliminated: false,
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
},
{
seat: 1, name: 'Bob', life: 40, eliminated: false,
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
},
];
describe('PhaseBar', () => {
it('highlights the active phase tab', () => {
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
const main1 = screen.getByText('main1');
expect(main1).toHaveClass('bg-accent-blue');
});
it('does not highlight inactive phase tabs', () => {
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
const untap = screen.getByText('untap');
expect(untap).not.toHaveClass('bg-accent-blue');
expect(untap).toHaveClass('bg-[#252a42]');
});
it('shows active player name', () => {
render(<PhaseBar phase="main1" activeSeat={0} players={players} />);
expect(screen.getByText(/P0: Alice/)).toBeInTheDocument();
});
});
@@ -1,32 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import PlayerZone from '../components/PlayerZone';
import type { Player } from '../types/game';
function makePlayer(overrides: Partial<Player> = {}): Player {
return {
seat: 0, name: 'Alice', life: 40, eliminated: false,
hand: [], battlefield: [], graveyard: [], exile: [], command_zone: [], hand_count: 7,
...overrides,
};
}
describe('PlayerZone life colors', () => {
it('uses life-high color for life >= 30', () => {
render(<PlayerZone player={makePlayer({ life: 35 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
const life = screen.getByText('35');
expect(life).toHaveClass('text-life-high');
});
it('uses life-med color for life >= 15 and < 30', () => {
render(<PlayerZone player={makePlayer({ life: 20 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
const life = screen.getByText('20');
expect(life).toHaveClass('text-life-med');
});
it('uses life-low color for life < 15', () => {
render(<PlayerZone player={makePlayer({ life: 10 })} isActive={false} isThinking={false} seatColour="#4696ff" />);
const life = screen.getByText('10');
expect(life).toHaveClass('text-life-low');
});
});
@@ -1,78 +0,0 @@
import { describe, it, expect } from 'vitest';
import { gameReducer, initialState } from '../store/gameReducer';
import type { GameState, Card, NarrationLine } from '../types/game';
const mockGameState: GameState = {
game_id: 'g1',
turn: 1,
current_phase: 'main1',
active_seat: 0,
players: [],
stack: [],
game_over: false,
winner: null,
};
const mockCard: Card = {
name: 'Sol Ring',
type: 'Artifact',
tapped: false,
is_commander: false,
oracle: '',
cmc: 1,
};
const mockLine: NarrationLine = {
text: 'Test narration',
isSystem: false,
timestamp: Date.now(),
};
describe('gameReducer', () => {
it('SET_STATE sets the game state', () => {
const result = gameReducer(initialState, { type: 'SET_STATE', payload: mockGameState });
expect(result.gameState).toEqual(mockGameState);
});
it('SET_HAND sets the hand cards', () => {
const cards = [mockCard];
const result = gameReducer(initialState, { type: 'SET_HAND', cards });
expect(result.hand).toEqual(cards);
});
it('ADD_NARRATION appends a line', () => {
const result = gameReducer(initialState, { type: 'ADD_NARRATION', line: mockLine });
expect(result.narration).toHaveLength(1);
expect(result.narration[0].text).toBe('Test narration');
});
it('ADD_NARRATION caps at 120 lines', () => {
let state = initialState;
for (let i = 0; i < 130; i++) {
state = gameReducer(state, {
type: 'ADD_NARRATION',
line: { text: `line ${i}`, isSystem: false, timestamp: Date.now() },
});
}
expect(state.narration).toHaveLength(120);
expect(state.narration[0].text).toBe('line 10');
});
it('SET_THINKING adds and removes seats', () => {
let state = gameReducer(initialState, { type: 'SET_THINKING', seat: 2, active: true });
expect(state.thinkingSeats.has(2)).toBe(true);
state = gameReducer(state, { type: 'SET_THINKING', seat: 2, active: false });
expect(state.thinkingSeats.has(2)).toBe(false);
});
it('GAME_OVER sets winner and reason', () => {
const result = gameReducer(initialState, { type: 'GAME_OVER', winner: 1, reason: 'elimination' });
expect(result.gameOver).toEqual({ winner: 1, reason: 'elimination' });
});
it('SET_STATUS sets status text', () => {
const result = gameReducer(initialState, { type: 'SET_STATUS', status: 'Connected' });
expect(result.status).toBe('Connected');
});
});
@@ -1,92 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useGameSocket } from '../hooks/useGameSocket';
// Mock WebSocket
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static instances: MockWebSocket[] = [];
url: string;
readyState = 1; // OPEN
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
sent: string[] = [];
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
// Simulate connection open
setTimeout(() => this.onopen?.(new Event('open')), 0);
}
send(data: string) {
this.sent.push(data);
}
close() {
this.readyState = 3;
}
simulateMessage(data: unknown) {
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }));
}
}
describe('useGameSocket', () => {
beforeEach(() => {
MockWebSocket.instances = [];
vi.stubGlobal('WebSocket', MockWebSocket);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('dispatches state on WS message', () => {
const { result } = renderHook(() => useGameSocket('game-1'));
const ws = MockWebSocket.instances[0];
const mockState = {
game_id: 'game-1', turn: 1, current_phase: 'main1', active_seat: 0,
players: [], stack: [], game_over: false, winner: null,
};
act(() => {
ws.simulateMessage({ type: 'state', payload: mockState });
});
expect(result.current.gameState).toEqual(mockState);
});
it('dispatches hand cards on WS message', () => {
const { result } = renderHook(() => useGameSocket('game-1'));
const ws = MockWebSocket.instances[0];
const cards = [{ name: 'Sol Ring', type: 'Artifact', tapped: false, is_commander: false, oracle: '', cmc: 1 }];
act(() => {
ws.simulateMessage({ type: 'hand', cards });
});
expect(result.current.hand).toEqual(cards);
});
it('sends heartbeat ping every 15s', () => {
renderHook(() => useGameSocket('game-1'));
const ws = MockWebSocket.instances[0];
act(() => {
vi.advanceTimersByTime(15000);
});
expect(ws.sent).toContain(JSON.stringify({ type: 'ping' }));
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

-54
View File
@@ -1,54 +0,0 @@
import type { GameState } from '../types/game';
import { SEAT_COLOURS } from '../constants';
import PlayerZone from './PlayerZone';
import CardChip from './CardChip';
interface BoardProps {
gameState: GameState;
thinkingSeats: Set<number>;
}
export default function Board({ gameState, thinkingSeats }: BoardProps) {
const { players, active_seat, stack } = gameState;
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
const renderZone = (seat: number, className: string) => {
const player = getPlayer(seat);
if (!player) return <div className={className} />;
return (
<div className={className}>
<PlayerZone
player={player}
isActive={active_seat === seat}
isThinking={thinkingSeats.has(seat)}
seatColour={SEAT_COLOURS[seat]}
/>
</div>
);
};
return (
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
{renderZone(1, 'row-start-1 col-start-1')}
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
<div className="flex flex-wrap gap-0.5">
{stack.map((card, i) => (
<CardChip key={i} card={card} />
))}
</div>
</div>
{renderZone(2, 'row-start-1 col-start-3')}
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
<div className="row-start-2 col-start-1" />
<div className="row-start-2 col-start-3" />
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
{renderZone(0, 'row-start-3 col-start-1')}
{renderZone(3, 'row-start-3 col-start-3')}
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
import type { Card } from '../types/game';
interface CardChipProps {
card: Card;
onClick?: () => void;
}
export default function CardChip({ card, onClick }: CardChipProps) {
return (
<span
onClick={onClick}
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
card.tapped ? 'opacity-55 italic' : ''
}`}
>
{card.name}
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
</span>
);
}
@@ -1,40 +0,0 @@
import type { Player } from '../types/game';
import { SEAT_COLOURS } from '../constants';
interface GameOverOverlayProps {
show: boolean;
winner: Player | null;
reason: string;
players: Player[];
}
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
if (!show) return null;
return (
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
{winner && (
<h2
className="text-4xl font-bold"
style={{ color: SEAT_COLOURS[winner.seat] }}
>
{winner.name} Wins!
</h2>
)}
<p className="text-base text-[#aaa] mt-2">{reason}</p>
<ul className="mt-6 space-y-1">
{players.map((p) => (
<li key={p.seat} className="text-sm">
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
{p.name} — {p.life} life
{p.eliminated && (
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
)}
</li>
))}
</ul>
</div>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import type { Card } from '../types/game';
interface HandCardProps {
card: Card;
index: number;
isSelected: boolean;
onClick: () => void;
}
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
return (
<button
onClick={onClick}
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
isSelected
? 'border border-[#ffdc32] bg-card-selected'
: 'bg-card-hand border border-[#5060a0]'
}`}
>
{card.name}
</button>
);
}
-54
View File
@@ -1,54 +0,0 @@
import { useState } from 'react';
import type { Card } from '../types/game';
import HandCard from './HandCard';
interface MoveBarProps {
hand: Card[];
humanSeat: number;
onPass: () => void;
onConcede: () => void;
onPlayCard: (index: number) => void;
status: string;
}
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const handleCardClick = (index: number) => {
if (selectedIndex === index) {
onPlayCard(index);
setSelectedIndex(null);
} else {
setSelectedIndex(index);
}
};
return (
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
<button
onClick={onPass}
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
>
Pass Priority
</button>
<button
onClick={() => {
if (window.confirm('Are you sure you want to concede?')) onConcede();
}}
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
>
Concede
</button>
{hand.map((card, i) => (
<HandCard
key={i}
card={card}
index={i}
isSelected={selectedIndex === i}
onClick={() => handleCardClick(i)}
/>
))}
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
</div>
);
}
@@ -1,68 +0,0 @@
import { useRef, useEffect, useState } from 'react';
import type { NarrationLine } from '../types/game';
import { SEAT_COLOURS } from '../constants';
interface NarrationPanelProps {
lines: NarrationLine[];
thinkingSeats: Set<number>;
}
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dots, setDots] = useState('.');
// Auto-scroll on new lines
useEffect(() => {
const el = containerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length]);
// Cycling dots for thinking animation
useEffect(() => {
if (thinkingSeats.size === 0) return;
const interval = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
}, 500);
return () => clearInterval(interval);
}, [thinkingSeats.size]);
const displayed = lines.slice(-120);
return (
<div
ref={containerRef}
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
>
{displayed.map((line, i) => {
let colorClass = 'text-[#e8e8e8]';
let style: React.CSSProperties | undefined;
if (line.isSystem) {
colorClass = 'text-[#556]';
} else if (line.seat !== undefined) {
colorClass = '';
style = { color: SEAT_COLOURS[line.seat] };
}
return (
<div
key={i}
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
style={style}
>
{line.text}
</div>
);
})}
{thinkingSeats.size > 0 &&
Array.from(thinkingSeats).map((seat) => (
<div
key={`thinking-${seat}`}
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
>
P{seat} thinking{dots}
</div>
))}
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
import type { Phase, Player } from '../types/game';
import { PHASES, SEAT_COLOURS } from '../constants';
interface PhaseBarProps {
phase: Phase;
activeSeat: number;
players: Player[];
}
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
const activePlayer = players.find((p) => p.seat === activeSeat);
return (
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
{PHASES.map((p) => (
<button
key={p}
className={`text-[11px] rounded px-2 py-0.5 ${
p === phase
? 'bg-accent-blue text-white'
: 'bg-[#252a42] text-[#888]'
}`}
>
{p}
</button>
))}
{activePlayer && (
<span
className="text-[11px] ml-auto font-bold"
style={{ color: SEAT_COLOURS[activeSeat] }}
>
P{activeSeat}: {activePlayer.name}
</span>
)}
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import type { Player } from '../types/game';
import CardChip from './CardChip';
interface PlayerZoneProps {
player: Player;
isActive: boolean;
isThinking: boolean;
seatColour: string;
}
function lifeColor(life: number): string {
if (life >= 30) return 'text-life-high';
if (life >= 15) return 'text-life-med';
return 'text-life-low';
}
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
return (
<div
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
isThinking ? 'animate-pulse' : ''
} ${player.eliminated ? 'opacity-40' : ''}`}
style={isActive ? { borderColor: seatColour } : undefined}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-[#666]">
P{player.seat}: {player.name}
</span>
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
{player.life}
</span>
</div>
<div className="flex flex-wrap gap-0.5">
{player.battlefield.map((card, i) => (
<CardChip key={i} card={card} />
))}
</div>
</div>
);
}
-9
View File
@@ -1,9 +0,0 @@
import type { Phase } from './types/game';
export const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'] as const;
export const PHASES: Phase[] = [
'untap', 'upkeep', 'draw', 'main1',
'begin_combat', 'declare_attackers', 'declare_blockers', 'combat_damage',
'main2', 'end_step', 'cleanup',
];
-132
View File
@@ -1,132 +0,0 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
import { gameReducer, initialState } from '../store/gameReducer';
import type { WsIncoming, NarrationLine } from '../types/game';
export function useGameSocket(gameId: string) {
const [state, dispatch] = useReducer(gameReducer, initialState);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => {
const url = `ws://${window.location.host}/ws/game/${gameId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
dispatch({ type: 'SET_STATUS', status: 'Connected' });
};
ws.onmessage = (event) => {
const msg: WsIncoming = JSON.parse(event.data);
switch (msg.type) {
case 'state':
dispatch({ type: 'SET_STATE', payload: msg.payload });
break;
case 'hand':
dispatch({ type: 'SET_HAND', cards: msg.cards });
break;
case 'event': {
const line: NarrationLine = {
text: msg.text,
seat: msg.seat,
isSystem: false,
timestamp: Date.now(),
};
dispatch({ type: 'ADD_NARRATION', line });
break;
}
case 'phase': {
const line: NarrationLine = {
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
isSystem: true,
timestamp: Date.now(),
};
dispatch({ type: 'ADD_NARRATION', line });
break;
}
case 'thinking':
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
break;
case 'game_over':
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
break;
case 'pong':
break;
case 'error':
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
break;
}
};
ws.onclose = () => {
if (!intentionalClose.current) {
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
reconnectTimeout.current = setTimeout(() => {
connect();
}, 3000);
}
};
ws.onerror = () => {
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
};
}, [gameId]);
// Connect on mount
useEffect(() => {
intentionalClose.current = false;
connect();
return () => {
intentionalClose.current = true;
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
}, [connect]);
// Heartbeat ping every 15s
useEffect(() => {
heartbeatInterval.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 15000);
return () => {
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
};
}, []);
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
}
}, []);
const sendConcede = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'concede' }));
}
}, []);
return {
gameState: state.gameState,
narration: state.narration,
thinking: state.thinkingSeats,
hand: state.hand,
gameOver: state.gameOver,
status: state.status,
sendMove,
sendConcede,
};
}
-163
View File
@@ -1,163 +0,0 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
export interface Seat {
seat: number;
name: string;
ready: boolean;
isAi: boolean;
}
export interface RoomState {
roomId: string;
roomName: string;
host: string;
seats: (Seat | null)[];
}
export interface ChatMessage {
role: 'PLAYER' | 'AI' | 'SYSTEM';
name: string;
text: string;
timestamp: number;
}
type LobbyAction =
| { type: 'SET_ROOM'; payload: RoomState }
| { type: 'ADD_CHAT'; message: ChatMessage }
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
| { type: 'LAUNCH'; gameId: string };
interface LobbyState {
roomState: RoomState | null;
chat: ChatMessage[];
launchedGameId: string | null;
}
const initialState: LobbyState = {
roomState: null,
chat: [],
launchedGameId: null,
};
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
switch (action.type) {
case 'SET_ROOM':
return { ...state, roomState: action.payload };
case 'ADD_CHAT':
return { ...state, chat: [...state.chat, action.message].slice(-50) };
case 'SET_CHAT_HISTORY':
return { ...state, chat: action.messages.slice(-50) };
case 'LAUNCH':
return { ...state, launchedGameId: action.gameId };
default:
return state;
}
}
export function useLobbySock(roomId: string, playerName: string) {
const [state, dispatch] = useReducer(lobbyReducer, initialState);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => {
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'lobby_state':
dispatch({ type: 'SET_ROOM', payload: msg.payload });
break;
case 'chat':
dispatch({
type: 'ADD_CHAT',
message: {
role: msg.role ?? 'PLAYER',
name: msg.name ?? '',
text: msg.text,
timestamp: Date.now(),
},
});
break;
case 'chat_history':
dispatch({
type: 'SET_CHAT_HISTORY',
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
role: m.role ?? 'SYSTEM',
name: m.name ?? '',
text: m.text,
timestamp: Date.now(),
})),
});
break;
case 'launch':
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
break;
case 'pong':
break;
}
};
ws.onclose = () => {
if (!intentionalClose.current) {
reconnectTimeout.current = setTimeout(() => connect(), 3000);
}
};
}, [roomId, playerName]);
useEffect(() => {
intentionalClose.current = false;
connect();
return () => {
intentionalClose.current = true;
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
}, [connect]);
// Heartbeat ping every 15s
useEffect(() => {
heartbeatInterval.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 15000);
return () => {
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
};
}, []);
const sendReady = useCallback((seat: number) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
}
}, []);
const sendChat = useCallback((text: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
}
}, []);
const sendLaunch = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'launch' }));
}
}, []);
return {
roomState: state.roomState,
chat: state.chat,
launchedGameId: state.launchedGameId,
sendReady,
sendChat,
sendLaunch,
};
}
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
-53
View File
@@ -1,53 +0,0 @@
import { useParams } from 'react-router-dom';
import { useGameSocket } from '../hooks/useGameSocket';
import PhaseBar from '../components/PhaseBar';
import Board from '../components/Board';
import NarrationPanel from '../components/NarrationPanel';
import MoveBar from '../components/MoveBar';
import GameOverOverlay from '../components/GameOverOverlay';
export default function GamePage() {
const { gameId } = useParams<{ gameId: string }>();
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
useGameSocket(gameId!);
if (!gameState) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
Connecting…
</div>
);
}
const winnerPlayer = gameOver
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
: null;
return (
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
<PhaseBar
phase={gameState.current_phase}
activeSeat={gameState.active_seat}
players={gameState.players}
/>
<div className="flex flex-1 overflow-hidden">
<Board gameState={gameState} thinkingSeats={thinking} />
<NarrationPanel lines={narration} thinkingSeats={thinking} />
</div>
<MoveBar
hand={hand}
humanSeat={0}
onPass={() => sendMove('pass')}
onConcede={sendConcede}
onPlayCard={(index) => sendMove('play_card', { index })}
status={status}
/>
<GameOverOverlay
show={gameOver !== null}
winner={winnerPlayer}
reason={gameOver?.reason ?? ''}
players={gameState.players}
/>
</div>
);
}
-141
View File
@@ -1,141 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
interface RoomSummary {
roomId: string;
name: string;
host: string;
playerCount: number;
}
export default function LobbyPage() {
const navigate = useNavigate();
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(true);
// Create room form state
const [roomName, setRoomName] = useState('');
const [hostName, setHostName] = useState('');
const [aiSlots, setAiSlots] = useState(0);
const [password, setPassword] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
fetch('/api/v1/lobby/rooms')
.then((res) => res.json())
.then((data) => setRooms(data))
.catch(() => setRooms([]))
.finally(() => setLoading(false));
}, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
try {
const res = await fetch('/api/v1/lobby/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roomName,
host: hostName,
ai_slots: aiSlots,
password: password || undefined,
}),
});
const data = await res.json();
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
} catch {
setCreating(false);
}
};
return (
<div className="min-h-screen bg-bg-base text-white p-6">
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
{/* Room List */}
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
{loading ? (
<p className="text-text-muted text-sm">Loading...</p>
) : rooms.length === 0 ? (
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
) : (
<ul className="space-y-2">
{rooms.map((room) => (
<li key={room.roomId}>
<button
onClick={() => navigate(`/room/${room.roomId}`)}
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
>
<span className="font-medium">{room.name}</span>
<span className="text-text-muted text-sm ml-2">
Host: {room.host} · {room.playerCount}/4 players
</span>
</button>
</li>
))}
</ul>
)}
</div>
{/* Create Room Form */}
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="block text-sm text-text-muted mb-1">Room Name</label>
<input
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
required
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Your Name</label>
<input
type="text"
value={hostName}
onChange={(e) => setHostName(e.target.value)}
required
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">AI Players</label>
<select
value={aiSlots}
onChange={(e) => setAiSlots(Number(e.target.value))}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value={0}>0 AI players</option>
<option value={1}>1 AI player</option>
<option value={2}>2 AI players</option>
<option value={3}>3 AI players</option>
</select>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<button
type="submit"
disabled={creating}
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Room'}
</button>
</form>
</div>
</div>
</div>
);
}
-220
View File
@@ -1,220 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { useLobbySock } from '../hooks/useLobbySock';
import type { ChatMessage } from '../hooks/useLobbySock';
const ROLE_COLORS: Record<string, string> = {
PLAYER: 'text-accent-blue',
AI: 'text-accent-purple',
SYSTEM: 'text-text-muted',
};
function ChatBadge({ role }: { role: string }) {
return (
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
[{role}]
</span>
);
}
export default function RoomPage() {
const { roomId } = useParams<{ roomId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isSpectator = searchParams.get('spectate') === '1';
const nameFromUrl = searchParams.get('name') ?? '';
const [playerName, setPlayerName] = useState(nameFromUrl);
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
const [chatInput, setChatInput] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
useLobbySock(roomId!, nameConfirmed ? playerName : '');
// Redirect when game launches
useEffect(() => {
if (launchedGameId) {
navigate(`/game/${launchedGameId}`);
}
}, [launchedGameId, navigate]);
// Auto-scroll chat
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chat.length]);
// Determine own seat
const mySeat = roomState?.seats.findIndex(
(s) => s && !s.isAi && s.name === playerName,
) ?? -1;
const isHost = mySeat === 0;
if (!nameConfirmed) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
}}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
autoFocus
/>
<button
onClick={() => playerName.trim() && setNameConfirmed(true)}
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
>
Join Room
</button>
</div>
</div>
);
}
if (!roomState) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
Connecting to room...
</div>
);
}
const handleSendChat = () => {
if (chatInput.trim()) {
sendChat(chatInput.trim());
setChatInput('');
}
};
return (
<div className="flex flex-col h-screen bg-bg-base text-white">
{/* Header */}
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
</div>
{isSpectator && (
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
SPECTATING
</span>
)}
</div>
<div className="flex flex-1 overflow-hidden">
{/* Seat Grid */}
<div className="flex-1 p-4">
<div className="grid grid-cols-2 gap-3 max-w-lg">
{[0, 1, 2, 3].map((seatIdx) => {
const seat = roomState.seats[seatIdx];
return (
<div
key={seatIdx}
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
>
{seat ? (
<>
<span className="text-sm font-medium">
{seat.isAi ? '🤖 AI' : seat.name}
</span>
<span className="text-xs text-text-muted mt-1">
Seat {seatIdx}
</span>
{!seat.isAi && (
<span
className={`text-xs mt-1 font-bold ${
seat.ready ? 'text-life-high' : 'text-life-med'
}`}
>
{seat.ready ? 'READY' : 'Not Ready'}
</span>
)}
</>
) : (
<>
<span className="text-text-muted text-sm">Waiting...</span>
<span className="text-xs text-text-muted mt-1">
Seat {seatIdx}
</span>
</>
)}
</div>
);
})}
</div>
{/* Action buttons */}
{!isSpectator && (
<div className="mt-4 flex gap-3">
{mySeat >= 0 && (
<button
onClick={() => sendReady(mySeat)}
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
>
Toggle Ready
</button>
)}
{isHost && (
<button
onClick={async () => {
try {
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
method: 'POST',
});
const data = await res.json();
if (data.gameId) {
navigate(`/game/${data.gameId}`);
}
} catch {
// launch via WS fallback
sendLaunch();
}
}}
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
>
Launch Game
</button>
)}
</div>
)}
</div>
{/* Chat Panel */}
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
Chat
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{chat.map((msg: ChatMessage, i: number) => (
<div key={i} className="text-[11px] leading-[1.4] break-words">
<ChatBadge role={msg.role} />
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
<span className="text-text-body">{msg.text}</span>
</div>
))}
<div ref={chatEndRef} />
</div>
{!isSpectator && (
<div className="border-t border-border-sep p-2">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSendChat();
}}
placeholder="Type a message..."
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
/>
</div>
)}
</div>
</div>
</div>
);
}
-391
View File
@@ -1,391 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { SEAT_COLOURS } from '../constants';
import type { SimDeck, SimResult } from '../types/simulator';
const SEAT_LABELS = ['Seat 1 (You)', 'Seat 2', 'Seat 3', 'Seat 4'];
type SimStatus = 'idle' | 'running' | 'complete' | 'error';
export default function SimulatorPage() {
// Deck list from API
const [decks, setDecks] = useState<SimDeck[]>([]);
const [loadingDecks, setLoadingDecks] = useState(true);
// Slot selections (4 slots, null = empty)
const [slots, setSlots] = useState<(number | null)[]>([null, null, null, null]);
// Config
const [numGames, setNumGames] = useState(10);
const [engineType, setEngineType] = useState('heuristic');
const [recordLogs, setRecordLogs] = useState(true);
// Sim state
const [simId, setSimId] = useState<string | null>(null);
const [status, setStatus] = useState<SimStatus>('idle');
const [completed, setCompleted] = useState(0);
const [total, setTotal] = useState(0);
const [result, setResult] = useState<SimResult | null>(null);
const [games, setGames] = useState<unknown[]>([]);
const [error, setError] = useState<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Fetch decks on mount
useEffect(() => {
fetch('/api/decks')
.then((res) => res.json())
.then((data) => {
const list = (data.decks || []).map((d: Record<string, unknown>) => ({
id: d.id as number,
name: d.name as string,
commander_name: (d.commander_name || '') as string,
color_identity: (d.color_identity || []) as string[],
}));
setDecks(list);
})
.catch(() => setDecks([]))
.finally(() => setLoadingDecks(false));
}, []);
// Polling
useEffect(() => {
if (status !== 'running' || !simId) return;
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/sim/status?simId=${simId}`);
const data = await res.json();
setCompleted(data.completed ?? 0);
setTotal(data.total ?? 0);
if (data.status === 'complete') {
setStatus('complete');
// Fetch results
const rres = await fetch(`/api/sim/result?simId=${simId}`);
const rdata = await rres.json();
setResult(rdata.summary ?? null);
setGames(rdata.games ?? []);
} else if (data.status === 'error') {
setStatus('error');
setError(data.error || 'Simulation failed');
}
} catch {
// keep polling
}
}, 1000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [status, simId]);
const selectedCount = slots.filter((s) => s !== null).length;
const canRun = selectedCount >= 2 && status !== 'running';
const handleRun = async () => {
const deckIds = slots.filter((s): s is number => s !== null);
setStatus('running');
setCompleted(0);
setTotal(numGames);
setResult(null);
setGames([]);
setError(null);
try {
const res = await fetch('/api/sim/run-n-player', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deckIds,
numGames,
recordLogs,
engineType,
}),
});
const data = await res.json();
if (!res.ok) {
setStatus('error');
setError(data.error || 'Failed to start simulation');
return;
}
setSimId(data.simId);
setTotal(data.total);
} catch {
setStatus('error');
setError('Network error');
}
};
const setSlot = (idx: number, value: number | null) => {
setSlots((prev) => {
const next = [...prev];
next[idx] = value;
return next;
});
};
const getDeckById = (id: number) => decks.find((d) => d.id === id);
return (
<div className="min-h-screen bg-bg-base text-white p-6">
<div className="max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Batch Simulator</h1>
<p className="text-text-muted text-sm mb-6">
Run N-player Monte Carlo simulations with your decks.
</p>
{/* Deck Selection */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Deck Slots</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{SEAT_LABELS.map((label, idx) => {
const selected = slots[idx];
const deck = selected !== null ? getDeckById(selected) : null;
return (
<div
key={idx}
className="bg-bg-zone rounded-md p-4"
style={{
border: `2px solid ${selected !== null ? SEAT_COLOURS[idx] : '#3a4060'}`,
boxShadow: selected !== null ? `0 0 8px ${SEAT_COLOURS[idx]}40` : 'none',
}}
>
<div
className="text-sm font-medium mb-2"
style={{ color: SEAT_COLOURS[idx] }}
>
{label}
</div>
<select
value={selected ?? ''}
onChange={(e) => setSlot(idx, e.target.value ? Number(e.target.value) : null)}
className="w-full bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="">— Empty —</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
{deck && (
<div className="mt-2 text-xs text-text-muted">
{deck.commander_name && (
<div className="truncate">{deck.commander_name}</div>
)}
{deck.color_identity.length > 0 && (
<div>{deck.color_identity.join(' ')}</div>
)}
</div>
)}
</div>
);
})}
</div>
{loadingDecks && (
<p className="text-text-muted text-sm mt-2">Loading decks...</p>
)}
</div>
{/* Config Row */}
<div className="flex flex-wrap items-end gap-4 mb-6 bg-bg-zone rounded-md border border-border-zone p-4">
<div>
<label className="block text-sm text-text-muted mb-1">Games</label>
<input
type="number"
min={1}
max={1000}
value={numGames}
onChange={(e) => setNumGames(Math.max(1, Math.min(1000, Number(e.target.value))))}
className="w-24 bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Engine</label>
<select
value={engineType}
onChange={(e) => setEngineType(e.target.value)}
className="bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="heuristic">Heuristic AI</option>
<option value="deepseek">DeepSeek AI</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="recordLogs"
checked={recordLogs}
onChange={(e) => setRecordLogs(e.target.checked)}
className="rounded"
/>
<label htmlFor="recordLogs" className="text-sm text-text-muted">
Record Logs
</label>
</div>
<button
onClick={handleRun}
disabled={!canRun}
className="bg-accent-blue text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed"
>
{status === 'running' ? (
<span className="flex items-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Running...
</span>
) : (
'Run Simulation'
)}
</button>
</div>
{/* Progress */}
{status === 'running' && (
<div className="mb-6">
<div className="flex justify-between text-sm text-text-muted mb-1">
<span>Progress</span>
<span>
{completed} / {total} games
</span>
</div>
<div className="w-full bg-bg-panel rounded-full h-3 overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all duration-300"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
</div>
)}
{/* Error */}
{status === 'error' && error && (
<div className="mb-6 bg-red-900/30 border border-red-700 rounded-md p-3 text-sm text-red-300">
{error}
</div>
)}
{/* Results */}
{status === 'complete' && result && (
<div className="space-y-6">
<div className="flex items-center gap-4 text-sm text-text-muted">
<span>{result.totalGames} games</span>
<span>{result.playerCount} players</span>
<span>{result.elapsedSeconds}s elapsed</span>
</div>
{/* Win Rate Bar Chart */}
<div className="bg-bg-zone rounded-md border border-border-zone p-4">
<h3 className="text-sm font-semibold mb-3">Win Rates</h3>
<div className="space-y-2">
{result.players.map((p) => (
<div key={p.seat} className="flex items-center gap-3">
<div className="w-28 text-sm truncate" style={{ color: SEAT_COLOURS[p.seat] }}>
{p.deckName}
</div>
<div className="flex-1 bg-bg-panel rounded-full h-5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${p.winRate}%`,
backgroundColor: SEAT_COLOURS[p.seat],
minWidth: p.winRate > 0 ? '2px' : '0',
}}
/>
</div>
<div className="w-16 text-right text-sm font-mono">
{p.winRate}%
</div>
</div>
))}
</div>
</div>
{/* Stats Table */}
<div className="bg-bg-zone rounded-md border border-border-zone overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-zone text-text-muted">
<th className="text-left px-3 py-2">Seat</th>
<th className="text-left px-3 py-2">Deck</th>
<th className="text-right px-3 py-2">Wins</th>
<th className="text-right px-3 py-2">Win%</th>
<th className="text-right px-3 py-2">Avg Turns</th>
<th className="text-right px-3 py-2">Avg Dmg</th>
<th className="text-right px-3 py-2">Avg Spells</th>
<th className="text-right px-3 py-2">Avg Creatures</th>
<th className="text-right px-3 py-2">Avg Removal</th>
</tr>
</thead>
<tbody>
{result.players.map((p) => {
const isWinner = result.players.every((o) => o.wins <= p.wins);
return (
<tr
key={p.seat}
className={`border-b border-border-zone ${isWinner ? 'font-semibold' : ''}`}
style={{
backgroundColor: `${SEAT_COLOURS[p.seat]}${isWinner ? '20' : '0a'}`,
}}
>
<td className="px-3 py-2" style={{ color: SEAT_COLOURS[p.seat] }}>
P{p.seat + 1}
</td>
<td className="px-3 py-2">{p.deckName}</td>
<td className="text-right px-3 py-2">{p.wins}</td>
<td className="text-right px-3 py-2">{p.winRate}%</td>
<td className="text-right px-3 py-2">{p.avgTurns}</td>
<td className="text-right px-3 py-2">{p.avgDamageDealt}</td>
<td className="text-right px-3 py-2">{p.avgSpellsCast}</td>
<td className="text-right px-3 py-2">{p.avgCreaturesPlayed}</td>
<td className="text-right px-3 py-2">{p.avgRemovalUsed}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Per-Game Logs (collapsible) */}
{games.length > 0 && (
<div className="bg-bg-zone rounded-md border border-border-zone">
<button
onClick={() => setShowLogs(!showLogs)}
className="w-full text-left px-4 py-3 text-sm font-medium flex justify-between items-center hover:bg-bg-panel transition-colors"
>
<span>Per-Game Results ({games.length} games)</span>
<span className="text-text-muted">{showLogs ? '▲' : '▼'}</span>
</button>
{showLogs && (
<div className="px-4 pb-3 max-h-96 overflow-y-auto">
<div className="space-y-1">
{games.map((g: unknown, i: number) => {
const game = g as Record<string, unknown>;
return (
<div
key={i}
className="flex items-center gap-3 text-xs text-text-muted py-1 border-b border-border-zone"
>
<span className="w-12 font-mono">#{game.gameNumber as number}</span>
<span>
Winner:{' '}
<span
style={{
color: SEAT_COLOURS[(game.winner ?? game.winningSeat ?? 0) as number],
}}
>
P{((game.winner ?? game.winningSeat ?? 0) as number) + 1}
</span>
</span>
<span>{game.turns as number} turns</span>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
-61
View File
@@ -1,61 +0,0 @@
import type { GameState, Card, NarrationLine } from '../types/game';
export type GameAction =
| { type: 'SET_STATE'; payload: GameState }
| { type: 'SET_HAND'; cards: Card[] }
| { type: 'ADD_NARRATION'; line: NarrationLine }
| { type: 'SET_THINKING'; seat: number; active: boolean }
| { type: 'GAME_OVER'; winner: number; reason: string }
| { type: 'SET_STATUS'; status: string };
export interface ReducerState {
gameState: GameState | null;
hand: Card[];
narration: NarrationLine[];
thinkingSeats: Set<number>;
gameOver: { winner: number; reason: string } | null;
status: string;
}
export const initialState: ReducerState = {
gameState: null,
hand: [],
narration: [],
thinkingSeats: new Set(),
gameOver: null,
status: '',
};
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
switch (action.type) {
case 'SET_STATE':
return { ...state, gameState: action.payload };
case 'SET_HAND':
return { ...state, hand: action.cards };
case 'ADD_NARRATION': {
const narration = [...state.narration, action.line];
return { ...state, narration: narration.slice(-120) };
}
case 'SET_THINKING': {
const thinkingSeats = new Set(state.thinkingSeats);
if (action.active) {
thinkingSeats.add(action.seat);
} else {
thinkingSeats.delete(action.seat);
}
return { ...state, thinkingSeats };
}
case 'GAME_OVER':
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
case 'SET_STATUS':
return { ...state, status: action.status };
default:
return state;
}
}
-1
View File
@@ -1 +0,0 @@
import '@testing-library/jest-dom';
-55
View File
@@ -1,55 +0,0 @@
export type Phase =
| 'untap' | 'upkeep' | 'draw' | 'main1'
| 'begin_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage'
| 'main2' | 'end_step' | 'cleanup';
export interface Card {
name: string;
type: string;
tapped: boolean;
is_commander: boolean;
oracle: string;
cmc: number;
}
export interface Player {
seat: number;
name: string;
life: number;
eliminated: boolean;
hand: Card[];
battlefield: Card[];
graveyard: Card[];
exile: Card[];
command_zone: Card[];
hand_count: number;
}
export interface GameState {
game_id: string;
turn: number;
current_phase: Phase;
active_seat: number;
players: Player[];
stack: Card[];
game_over: boolean;
winner: Player | null;
}
export type WsIncoming =
| { type: 'state'; payload: GameState }
| { type: 'event'; text: string; seat?: number }
| { type: 'phase'; phase: Phase; active_seat: number }
| { type: 'thinking'; seat: number; active: boolean }
| { type: 'elimination'; seat: number }
| { type: 'game_over'; winner: number; reason: string }
| { type: 'hand'; cards: Card[] }
| { type: 'pong' }
| { type: 'error'; message: string };
export interface NarrationLine {
text: string;
seat?: number;
isSystem: boolean;
timestamp: number;
}
Loaded 100 of 246 files, more files were not shown because too many files have changed in this diff. Show more