Compare commits

...
Author SHA1 Message Date
KoalaTrapLord 8c34cb0966 feat: Phase 8 — surface player_explanation in simulation results JSON
Extract player_explanation from validation dict in each turn log
entry within _run_game_loop(). The React frontend can now render
validator explanations when player_explanation is non-null.
2026-03-21 13:37:09 -04:00
KoalaTrapLord c67ce422d8 refactor: rename LM_STUDIO/LMStudioClient to LLM/LLMClient in routes/coach.py 2026-03-21 13:34:21 -04:00
KoalaTrapLord cb4b48e362 refactor: rename LMStudioClient to LLMClient in coach_service.py 2026-03-21 13:33:00 -04:00
KoalaTrapLord 4a8db4db6b refactor: rename LM_STUDIO/LMStudioClient to LLM/LLMClient in llm_client.py 2026-03-21 13:31:32 -04:00
KoalaTrapLord 9c0fa64a69 feat: Phase 7 — add validation field to decision log schema
Add "validation" key to _log_decision() entry dict in deepseek_brain.py.
Captures result.get("validation") (None if validator disabled)
for R1-14B output preservation and future analysis.
2026-03-21 13:31:08 -04:00
KoalaTrapLord c338f16965 refactor: rename LM_STUDIO_* to LLM_* in coach/config.py 2026-03-21 13:29:51 -04:00
KoalaTrapLord a7ab0e43a5 feat: Phase 6 — make primary brain env-configurable (BRAIN_* vars)
Replace hardcoded DEEPSEEK_API_BASE/DEEPSEEK_MODEL env vars in
_get_deepseek_brain() with new BRAIN_* environment variables:
- BRAIN_API_BASE (default: http://localhost:11434)
- BRAIN_MODEL (default: gpt-oss:20b)
- BRAIN_TEMPERATURE (default: 0.3)
- BRAIN_MAX_TOKENS (default: 1024)
- BRAIN_TIMEOUT (default: 30.0)

All params now passed directly to DeepSeekConfig constructor.
2026-03-21 13:21:58 -04:00
KoalaTrapLord 8b527d7c30 fix: remove duplicate ml_log=True kwarg in deepseek.py 2026-03-21 12:48:56 -04:00
KoalaTrapLord 36760dd922 fix(ml): rename log -> logger in dataset_builder.py to fix NameError 2026-03-21 12:42:02 -04:00
KoalaTrapLord 963a5db638 fix(ml): enable ml_log=True in deepseek route engine calls 2026-03-21 12:41:01 -04:00
KoalaTrapLord b1996a8deb fix: indent __init__ into DeepSeekGameEngine class body 2026-03-20 22:01:25 -04:00
KoalaTrapLord c44d0b32bb Update DeepSeek port from 1234 to 11434 in deepseek_engine.py 2026-03-20 21:55:10 -04:00
KoalaTrapLord a78df75014 Update DeepSeek port from 1234 to 11434 in SimulatorPage.tsx 2026-03-20 21:54:22 -04:00
KoalaTrapLord 9020d29745 Update DeepSeek port from 1234 to 11434 in deepseek_brain.py 2026-03-20 21:53:27 -04:00
KoalaTrapLord b0a34ae9e2 Update DeepSeek port from 1234 to 11434 in coach.py 2026-03-20 21:52:39 -04:00
KoalaTrapLord 4c4f5ce0ff Update DeepSeek API port from 1234 to 11434 in config.py 2026-03-20 21:51:42 -04:00
KoalaTrapLord 1e7f4b4e31 Update DeepSeek API port from 1234 to 11434 (Ollama) 2026-03-20 21:49:49 -04:00
KoalaTrapLord 488575f3df fix(routes): correct indentation of validator line in _run_sim_thread_deepseek 2026-03-20 21:42:11 -04:00
KoalaTrapLord fd7f0258f3 feat(routes): add validator singleton and /api/validator endpoints 2026-03-20 21:27:15 -04:00
KoalaTrapLord 7284b8617d feat(sim): wire DeepSeek-R1-14B validator into deepseek_engine 2026-03-20 21:16:16 -04:00
KoalaTrapLord 678cc13fe0 feat: enable ML logging by default in lab.py 2026-03-20 21:05:37 -04:00
KoalaTrapLord 19f83a546b feat: enable ML logging by default in shared.py 2026-03-20 21:01:40 -04:00
KoalaTrapLord c53e82bfeb feat(sim): add DeepSeek-R1-14B validator brain module 2026-03-20 17:20:30 -04:00
KoalaTrapLord f8b13703b8 fix: CAST_PATTERN now matches both 'cast' and 'casts' for Forge compatibility 2026-03-20 15:04:37 -04:00
KoalaTrapLord eb59a2cd2e fix: increase default clock from 120s to 300s for Commander games to prevent premature timeouts 2026-03-20 14:58:24 -04:00
KoalaTrapLord ff6040b53e Remove stray closing brace that would break compilation 2026-03-20 13:31:50 -04:00
KoalaTrapLord db7bc62d7e Fix commander damage tracking, add PLAYER_DAMAGE_SOURCE_PATTERN and diagnostic logging 2026-03-20 13:28:54 -04:00
KoalaTrapLord fbffe81846 feat: auto-detect commander card names from .dck files for commander cast tracking 2026-03-20 12:48:48 -04:00
KoalaTrapLord 9369cf5450 docs: fix adapter.py docstring to reference lab_api.py instead of deleted lab_api_new.py 2026-03-20 12:19:07 -04:00
KoalaTrapLord e38401a740 chore: remove obsolete lab_api_backup.py (superseded by lab_api.py + routes/) 2026-03-20 12:17:44 -04:00
KoalaTrapLord 111764615d chore: remove obsolete lab_api_new.py (superseded by lab_api.py + routes/) 2026-03-20 12:16:50 -04:00
KoalaTrapLord 2088da6fd8 fix: correct indentation in _resolve_forge_dir forge-gui sibling check 2026-03-20 11:33:42 -04:00
KoalaTrapLord a2fbc30b2b fix: resolve forge_dir to forge-gui sibling when res/ not found in forge-gui-desktop 2026-03-20 11:22:44 -04:00
KoalaTrapLord a62640668b fix: update DeepSeek endpoint IP in lab_api_backup.py 2026-03-20 11:11:07 -04:00
KoalaTrapLord b8e12dff1b fix: update DeepSeek endpoint IP in lab_api_new.py 2026-03-20 11:10:15 -04:00
KoalaTrapLord ea0a785174 fix: update DeepSeek endpoint IP in SimulatorPage.tsx 2026-03-20 11:09:27 -04:00
KoalaTrapLord 9f1277f233 fix: update DeepSeek endpoint IP in deepseek_engine.py 2026-03-20 11:08:32 -04:00
KoalaTrapLord 66ce887fe3 fix: update DeepSeek endpoint IP in deepseek_brain.py docstring 2026-03-20 11:07:38 -04:00
KoalaTrapLord 80f63f192a fix: update DeepSeek endpoint IP in coach.py 2026-03-20 11:06:30 -04:00
KoalaTrapLord 46ed2f5606 fix: update DeepSeek endpoint IP from 192.168.0.122 to 192.168.0.240 in simulator.html 2026-03-20 11:05:10 -04:00
KoalaTrapLord 93a421b90a fix: make colons optional in all regex patterns to match Forge output format (no colons after Game Outcome, Life, Add To Stack, Land, Zone Change, etc.); fix VERBOSE_TURN_PATTERN for 'Turn Turn N AiX' format; fix LIFE_CHANGE_PATTERN to use space instead of > separator 2026-03-20 09:26:02 -04:00
KoalaTrapLord ee95f26bc4 fix: restore double-backslash escaping in Ai regex patterns (fixes compilation errors) 2026-03-20 09:03:55 -04:00
KoalaTrapLord 2ee574f678 diag: add [COMBAT-LINE] logging to identify Forge verbose output format for combat stats parsing 2026-03-20 08:15:03 -04:00
KoalaTrapLord 473a2bdcc7 fix: make Ai regex patterns support both Ai(N) and AiN formats for Forge output compatibility 2026-03-19 23:29:53 -04:00
KoalaTrapLord a6e50ee0f0 feat: aggregate combat stats in StatsAggregator 2026-03-19 22:45:05 -04:00
KoalaTrapLord e2a6a75fd9 feat: add combat stat fields to DeckSummary schema 2026-03-19 22:39:51 -04:00
KoalaTrapLord 030bdf2b81 fix: CAST_PATTERN and LAND_PLAY_PATTERN regex to match Forge verbose log prefixes
- CAST_PATTERN: Add optional (?:Add To Stack:\s*)? prefix to match Forge's verbose log format
- LAND_PLAY_PATTERN: Add optional (?:Land:\s*)? prefix to match Forge's verbose log format
- Add [PARSE] diagnostic logging after combat stats parsing to confirm spells/lands/cmdr stats per seat

Root cause: Forge verbose output prefixes cast lines with "Add To Stack: " and land lines with "Land: "
but the regexes expected Ai(N) at the start of the line, causing 0 matches for all combat stats.
2026-03-19 22:06:17 -04:00
rgriffith c83f7e0141 fix: repair broken if-statement on line 162 2026-03-19 21:45:42 -04:00
KoalaTrapLord e5377ef4bc fix: correct indentation and add forge-gui candidate path 2026-03-19 18:24:57 -04:00
KoalaTrapLord 56ea2602b4 fix: scan sibling dirs for res/ in _resolve_forge_dir (finds forge-gui/res) 2026-03-19 18:19:32 -04:00
KoalaTrapLord d86109c5c0 Fix duplicate WHERE clause in _load_deck_cards_by_name SQL query 2026-03-19 17:54:34 -04:00
KoalaTrapLord d9dd098ca3 fix: add power, toughness, keywords to _load_deck_cards_by_name SQL query 2026-03-19 17:43:57 -04:00
KoalaTrapLord 69ce5b340e fix: normalize indentation in _finish_sim (line 184-185) 2026-03-19 17:13:13 -04:00
KoalaTrapLord 103a74a0e1 fix: update DeepSeek brain default api_base to 192.168.0.240:1234 2026-03-19 17:11:56 -04:00
KoalaTrapLord 147121582b fix: update DeepSeek/LM Studio default URL to 192.168.0.240:1234 2026-03-19 17:10:14 -04:00
KoalaTrapLord 8be96ab47d fix: use coach.report_generator for deck report generation 2026-03-19 17:05:03 -04:00
KoalaTrapLord e2653b3f72 fix: align sim results path with coach (routes/results) 2026-03-19 17:03:00 -04:00
KoalaTrapLord 7efac34279 fix: persist sim results to disk + auto-generate deck reports 2026-03-19 16:59:55 -04:00
KoalaTrapLord b00df14f30 fix: use card_name key from _load_deck_cards_by_name (KeyError: 'name') 2026-03-19 16:51:27 -04:00
KoalaTrapLord 3542efd982 fix: align build_java_command with LabCli picocli args (--forge-jar, --forge-dir, --deck1..4) 2026-03-19 16:42:03 -04:00
KoalaTrapLord 1f93b5510d fix: add missing load_weights() to rules.py 2026-03-19 15:49:00 -04:00
KoalaTrapLord 5c31be6023 fix: add Config extra=ignore to DeckCardWithStatus to prevent 422 from adapter extra fields 2026-03-19 15:34:18 -04:00
KoalaTrapLord 76139477be fix: replace pplx.model with getattr, update V3 error messages for Ollama 2026-03-19 15:26:15 -04:00
KoalaTrapLord 0e5d7ba7d3 fix: harden _find_java17 to search multiple vendors, JAVA_HOME, and warn on wrong version 2026-03-19 15:23:44 -04:00
KoalaTrapLord 4ec6ed57d5 fix: add missing urllib imports to deckbuilder.py for Scryfall card lookup 2026-03-19 15:11:33 -04:00
KoalaTrapLord 08d81a4336 fix: remove stale Perplexity API key error message, allow Ollama/local mode to generate without hard block 2026-03-19 14:58:39 -04:00
KoalaTrapLord f6ed40a253 fix: correct indentation in init_coach_service V3 deck gen block 2026-03-19 14:46:16 -04:00
KoalaTrapLord 9fd39e42c7 feat(phase-10): add DeckBuilderAdapter unit tests 2026-03-19 14:35:00 -04:00
KoalaTrapLord 72107df2ae feat(phase-10): add comprehensive Pydantic model tests 2026-03-19 14:32:43 -04:00
KoalaTrapLord 2738bbdba4 feat: add scrython>=2.0.0 to requirements.txt 2026-03-19 14:28:38 -04:00
KoalaTrapLord 0bbc78ef23 fix: rewire routes/coach.py to use DeckBuilderAdapter + fix IndentationError 2026-03-19 14:02:23 -04:00
KoalaTrapLord c2b470e251 refactor: replace DeckGeneratorV3 import with DeckBuilderAdapter alias 2026-03-19 13:50:30 -04:00
KoalaTrapLord 6135feb644 feat: add run_substitution stub + clean up adapter 2026-03-19 13:49:13 -04:00
KoalaTrapLord b3b664872b refactor: rewire V3 deck gen init to use DeckBuilderAdapter (Ollama) 2026-03-19 13:41:15 -04:00
KoalaTrapLord a80e8908e1 fix: indent _deck_gen_v3_error inside if block in coach.py 2026-03-19 13:35:44 -04:00
KoalaTrapLord a8ef10d5b7 feat: add DeckBuilderAdapter to bridge Ollama pipeline to legacy API 2026-03-19 13:24:29 -04:00
KoalaTrapLord 86af4b6de5 fix: close missing paren in _call_pplx_api client.post() 2026-03-19 13:16:22 -04:00
KoalaTrapLord 332606d6b5 Merge pull request #62 from KoalaTrapLord/feat/deck-builder-phase0
feat: Commander AI Deck Builder (Ollama gpt-oss:20b)
2026-03-19 12:45:04 -04:00
KoalaTrapLord 70dfa3d3e0 fix: robust JSON extraction for LLM responses (_extract_json) 2026-03-19 12:29:49 -04:00
KoalaTrapLord f970b6d571 fix: api __init__ imports modules not classes 2026-03-19 12:08:26 -04:00
KoalaTrapLord 5ade916207 fix: make conversation log always visible with initial ready message 2026-03-19 11:50:52 -04:00
KoalaTrapLord cc53c58344 feat(phase-0): add deck builder deps (requests, bs4, ollama) 2026-03-19 11:33:45 -04:00
KoalaTrapLord 3e64aa67eb feat(phase-6): implement deck assembler (Ollama JSON parser) 2026-03-19 11:32:14 -04:00
KoalaTrapLord 70a7c07b81 feat: add conversation log panel below Generate Deck button 2026-03-19 11:31:10 -04:00
KoalaTrapLord 1b4e9f5a66 feat(phase-3): implement community scraper (Moxfield + Archidekt) 2026-03-19 11:30:53 -04:00
KoalaTrapLord c962d1f16a feat(phase-9): implement FastAPI + CLI entrypoint 2026-03-19 11:29:29 -04:00
KoalaTrapLord dcdf15c56a feat(phase-8): add deck_builder package init with public API 2026-03-19 11:28:08 -04:00
KoalaTrapLord b0214009f4 feat(phase-8): add api __init__ exports 2026-03-19 11:27:07 -04:00
KoalaTrapLord 5fe0ac2bd6 feat(phase-8): add core __init__ exports 2026-03-19 11:26:04 -04:00
KoalaTrapLord fc09249ad3 feat(phase-7): add pipeline __init__ exports 2026-03-19 11:24:47 -04:00
KoalaTrapLord d2c7783ce1 feat(phase-7): implement 7-step deck build pipeline 2026-03-19 11:23:38 -04:00
KoalaTrapLord 0ccf35f241 feat(phase-5): implement collection filter with CSV loader 2026-03-19 11:22:09 -04:00
KoalaTrapLord 9a5105b0d6 feat(phase-5): implement rules engine with ban list and validation 2026-03-19 11:19:42 -04:00
KoalaTrapLord 6a688d1cd0 feat(phase-4): implement Ollama gpt-oss:20b client with 6 core functions 2026-03-19 11:18:13 -04:00
KoalaTrapLord d9639a8414 feat(phase-3): implement EDHrec JSON client 2026-03-19 11:16:28 -04:00
KoalaTrapLord 633fd43b28 fix: replace sonar/sonar-pro model options with gpt-oss:20b in UI 2026-03-19 11:15:43 -04:00
KoalaTrapLord 45c72aadc9 feat(phase-2): implement Scryfall API client 2026-03-19 11:14:58 -04:00
KoalaTrapLord 81ab7ac34a feat(phase-1): implement Pydantic models for deck builder 2026-03-19 11:13:06 -04:00
KoalaTrapLord e0fe4144ae Create test_collection_filter.py 2026-03-19 10:49:06 -04:00
KoalaTrapLord 68aa6eac3f Create test_rules_engine.py 2026-03-19 10:48:23 -04:00
KoalaTrapLord c870dd35fb Create __init__.py 2026-03-19 10:47:49 -04:00
KoalaTrapLord 35ce67ed6f Create collection.csv.example 2026-03-19 10:47:18 -04:00
KoalaTrapLord 232750a951 Create main.py 2026-03-19 10:46:45 -04:00
KoalaTrapLord 46c1c8cb01 Create build_deck.py 2026-03-19 10:46:14 -04:00
KoalaTrapLord 64a4cd3c7c Create __init__.py 2026-03-19 10:45:36 -04:00
KoalaTrapLord 3f6b66fce3 Create deck_assembler.py 2026-03-19 10:44:53 -04:00
KoalaTrapLord 2887c09550 Create collection_filter.py 2026-03-19 10:44:18 -04:00
KoalaTrapLord 84d84a211f Create rules_engine.py 2026-03-19 10:43:45 -04:00
KoalaTrapLord 12f90dd276 Create models.py 2026-03-19 10:43:15 -04:00
KoalaTrapLord 0b755c8428 Create __init__.py 2026-03-19 10:42:40 -04:00
KoalaTrapLord 50f36f284c Create ollama_client.py 2026-03-19 10:42:12 -04:00
KoalaTrapLord 369cc9243f Create community_scraper.py 2026-03-19 10:41:45 -04:00
KoalaTrapLord 22d3b64c2d Create edhrec.py 2026-03-19 10:41:07 -04:00
KoalaTrapLord beb3dac4d5 Create scryfall.py 2026-03-19 10:40:40 -04:00
KoalaTrapLord 330801107c Create __init__.py 2026-03-19 10:40:11 -04:00
KoalaTrapLord 95c5a93584 feat(task-0.1): scaffold deck_builder subpackage structure 2026-03-19 10:39:21 -04:00
KoalaTrapLord 377a704a90 fix: update lab_api_new.py monolith to use local Ollama for deck gen 2026-03-19 10:32:56 -04:00
KoalaTrapLord cc481bd71b feat: switch deck generation from Perplexity to local Ollama (gpt-oss:20b) 2026-03-19 10:26:24 -04:00
KoalaTrapLord c6d02fd38c Fix bracket field handling: support both int and dict from AI response 2026-03-19 08:25:23 -04:00
KoalaTrapLord 0f2d3583ac Remove unsupported response_format from Perplexity API call 2026-03-19 08:14:44 -04:00
KoalaTrapLord 2b355ed9ba Fix NameError: _coach_embeddings not defined in V3 status endpoint 2026-03-19 08:05:26 -04:00
KoalaTrapLord b4d8c2f50e Fix indentation error on line 47 of forge_runner.py 2026-03-19 06:54:16 -04:00
KoalaTrapLord 9db1ed112f Increase _STALL_LIMIT from 300s to 600s (10 min) to prevent premature batch kills 2026-03-19 06:30:03 -04:00
KoalaTrapLord 75568bfdb1 fix: expose full V3 init traceback in _deck_gen_v3_error for easier debugging 2026-03-19 06:27:04 -04:00
KoalaTrapLord d834247885 Fix forge_runner.py: restore full file, fix sim mode arg
- Restored _run_deepseek_batch_thread and _get_deepseek_brain
  (accidentally removed in prior commit)
- Changed '-sim' to 'sim' — Forge expects positional mode, not flag
2026-03-19 10:13:11 +00:00
rgriffith 310a408509 feat: polish precon picker — card art, color pips, grid layout
- Add full CSS for precon grid: responsive card grid, hover effects,
  install button states, color pips, dark theme styling
- Build commander art_crop image URLs from set_code/number during
  precon download (Scryfall direct image redirect, no API call needed)
- Batch-fetch color_identity from Scryfall /cards/collection endpoint
  (2 requests for ~145 unique commanders) and store in precon index
- Widen precon modal to 900px for grid layout
- Auto-invalidate precon cache when enrichment fields are missing
- JS renderer now shows commander art crop + card body layout
- Bump cache-busting version to v=4
2026-03-19 10:10:40 +00:00
KoalaTrapLord ef7bde841f Fix forge_runner.py: sim mode arg + indentation corruption
- Changed '-sim' to 'sim' (Forge expects positional mode, not flag)
- Fixed indentation: build_java_command, _run_process_blocking,
  run_batch_subprocess were incorrectly nested inside get_java17()
- Fixed last_activity assignment stuck inside except block
- Added docstrings
2026-03-19 10:09:59 +00:00
KoalaTrapLord 87abfd801e Fix V3 500 error: replace json_schema response_format with json_object
- Sonar does not support response_format type json_schema
- Switch to {"type": "json_object"} which Sonar supports
- Keep json_schema/schema_name params for call-site compatibility (ignored)
- Add exc_info=True to V3 generate error logging for full tracebacks
2026-03-19 10:04:36 +00:00
rgriffith 8eba39cca7 fix: precon picker empty — mutate PRECON_INDEX in-place
The PRECON_INDEX list was imported by value in routes/shared.py.
When download_precon_database() or load_precon_index() reassigned it
with 'PRECON_INDEX = ...', the new list object was only visible in
precon_service module scope — shared.PRECON_INDEX (and thus the
/api/lab/precons endpoint) still pointed to the original empty [].

Fix: use PRECON_INDEX.clear() + .extend() instead of reassignment
so all modules sharing the same list reference see the updated data.
2026-03-19 10:01:51 +00:00
rgriffith bc9656a59f fix: add cache-busting query params to ai-lab.js and ai-lab.css
Browser was serving the old Base64-encoded ai-lab.js from cache.
Adding ?v=3 query parameter forces the browser to fetch the new
decoded JavaScript file.
2026-03-19 09:55:00 +00:00
rgriffith 86b96c09f9 fix: decode ai-lab.js from Base64 back to proper JavaScript
The entire ai-lab.js file (139KB) was stored as a single Base64-encoded
string instead of actual JavaScript. The browser loaded it as a script
but couldn't parse it — AiLab was never defined, so the batch sim panel
never rendered.

Decoded the file back to its proper 2531-line JavaScript source. The
file already includes auto-initialization at the bottom:
  if (document.readyState === 'loading')
    document.addEventListener('DOMContentLoaded', AiLab.init);
  else AiLab.init();

Also removed the now-redundant DOMContentLoaded listener from
index.html since the JS file handles init itself.
2026-03-19 09:47:18 +00:00
rgriffith 9a46b0f80c fix: remove duplicate Scryfall/helper defs from collection.py that shadowed working imports
collection.py re-defined _ScryfallCache, _scryfall_cache,
_scryfall_rate_limit, _fetch_scryfall_api, _enrich_from_scryfall,
_parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content,
_TYPE_PRIORITY, and _TYPE_TARGETS locally (lines 333-509).

These local definitions shadowed the correct imports from
routes/shared.py -> services/scryfall.py. Critically, the local
_fetch_scryfall_api used Request and urlopen without importing them,
causing a NameError at runtime that was silently swallowed by the
broad except clause — making every card fail Scryfall enrichment
during collection import.

Removed all ~470 lines of duplicate code. The import endpoint now
correctly uses the functions imported from shared.py, which have
proper urllib imports and a working Scryfall cache.
2026-03-19 09:43:26 +00:00
rgriffith 0d2da18783 fix: call AiLab.init() on DOMContentLoaded so batch sim panel renders
The ai-lab.js IIFE defines the AiLab global with an init() method that
calls renderPanel(), detectBackend(), loadAvailableDecks(),
loadMetaCommanders(), loadPrecons(), and bindEvents() — but nothing
ever invoked it. Added a DOMContentLoaded listener after the script tag
in index.html to trigger initialization.
2026-03-19 09:34:27 +00:00
Computer 6a84708c65 fix: wire DeckBuilderController and create prefabs in CreateDeckBuilderScene.cs 2026-03-19 08:30:11 +00:00
rgriffith 9b892e5289 fix: remove unsupported cursor:auto and normalize rgb() spacing in USS files 2026-03-19 08:05:54 +00:00
KoalaTrapLord f2a782f0bd feat(unity-ui): add MainMenu.uss styles for nav hub, server config, module cards 2026-03-19 04:00:31 -04:00
KoalaTrapLord da67879491 feat(unity-ui): add MainMenu.uxml nav hub with server config and module cards 2026-03-19 03:59:31 -04:00
KoalaTrapLord 18beb9192e feat(unity-ui): add UXML+USS layouts for all 5 GUI screens
- CollectionTracker.uxml  — card grid, filter sidebar, detail panel
- CoachPanel.uxml         — chat bubbles, analytics, mana curve chart
- DeckGenerator.uxml      — 4-step wizard (commander → strategy → generate → result)
- SimulationPanel.uxml    — 4-player config, progress bar, results table
- TrainingPanel.uxml      — stats, start/stop, live charts, log, model history

- CommanderLabShared.uss  — dark MTG theme shared across all screens
- CollectionTracker.uss
- CoachPanel.uss
- DeckGenerator.uss
- SimulationPanel.uss
- TrainingPanel.uss
2026-03-19 03:55:13 -04:00
rgriffith ce80b6d9c5 feat: implement SimulationController, fix API paths, wire Simulator scene 2026-03-19 07:46:44 +00:00
Computer d8e355de2b fix: append to Build Settings instead of overwrite, add Build All Scenes tool 2026-03-19 07:34:01 +00:00
Computer 9d2a043d3f fix: add EditorSceneManager.SaveScene to all scene creator scripts 2026-03-19 07:29:16 +00:00
KoalaTrapLord 85224f082e fix: handle wrapped JSON object from /api/ml/models endpoint
The API returns {"models":[...]} rather than a bare array.
Added MlModelsResponse wrapper class and updated LoadModelHistory
to unwrap the list before iterating, preventing the deserialization
exception in TrainingController.
2026-03-19 02:57:46 -04:00
Computer c6c3bfabf8 refactor: replace per-batch watchdog threads with single global watchdog 2026-03-19 06:56:56 +00:00
Computer c6329d377a fix: register missing /api/ml and /api/health routes 2026-03-19 06:48:41 +00:00
KoalaTrapLord 3f7f40d626 Make _call_pplx_api async with httpx.AsyncClient
- Replace blocking urlopen with async httpx.AsyncClient
- Add await at both call sites (deck_research, deck_generate_ai)
- Uvicorn event loop no longer stalled during Perplexity API calls
2026-03-19 06:32:18 +00:00
KoalaTrapLord bc023ab6f6 Fix indentation in coach.py _build_deck_report_from_db (2-space -> 4-space) 2026-03-19 06:26:27 +00:00
KoalaTrapLord 83a8f288b7 Fix scrambled deck_service.py: close SQL query in _load_deck_cards_by_name, remove orphaned lines 2026-03-19 06:24:13 +00:00
KoalaTrapLord e3f0787c73 Optimize deckgen: push colour-identity filtering into SQL with json_each
- Replace SELECT * + Python-side color loop in _get_collection_for_colors()
  with projected SELECT + NOT EXISTS json_each() sub-query
- Apply same pattern to _build_collection_summary(), removing card_fits_identity()
- Add partial index idx_ce_quantity on collection_entries(quantity) WHERE qty > 0
- Handle NULL and empty-string color_identity via CASE guard
- Fix colorless commander bug: old code skipped color check entirely for []
- Add scripts/bench_deckgen.py benchmark (3-5x speedup on filtered queries)
- Add tests/test_deckgen_color_filter.py with 7 regression tests
2026-03-19 06:18:32 +00:00
Computer 3802bc3d78 fix: resolve CS0103 errors in CreateDeckBuilderScene.cs and CreateCollectionScene.cs 2026-03-19 06:08:11 +00:00
KoalaTrapLord 2936fd37eb Refactor routes/ml.py: replace state dicts with dataclasses + threading locks
- Add @dataclass classes _TrainingState, _PPOState, _TournamentState with snapshot() method
- Wrap all state mutations in threading.Lock() for thread safety
- Replace dict bracket access with attribute access
- Remove unnecessary global declarations from pipeline functions
2026-03-19 06:06:11 +00:00
KoalaTrapLord cad2f00ec7 Fix all Unity C# errors: 4 truncated Editor scripts + 11 controller/model compilation fixes 2026-03-19 05:53:04 +00:00
KoalaTrapLord 6b50c6ee7d Add PreconListWrapper and CollectionResponse classes to DeckGeneratorController 2026-03-19 01:40:50 -04:00
KoalaTrapLord 40a166188a fix: unify DB connection management — init_collection_db() uses _get_db_conn()
Previously, init_collection_db() opened a separate sqlite3.connect() call,
ran migrations, then closed that connection — entirely outside the
thread-local pool that route handlers use via _get_db_conn().

This meant:
- The migration connection and the request-serving connection were
  independent objects with no ordering guarantee under concurrent startup.
- The warm-up connection was thrown away immediately, so every handler
  thread still had to open its own connection on first use.

Fix:
- init_collection_db() now calls _get_db_conn() directly, so schema
  creation and migrations run on the same thread-local connection that
  will serve subsequent requests.
- Added _close_db_conn() helper for explicit teardown on thread exit
  (e.g. FastAPI lifespan shutdown or worker thread cleanup).
- Removed the redundant standalone conn.close() that discarded the
  init connection without ever making it available to route handlers.
2026-03-19 01:24:24 -04:00
KoalaTrapLord a501a9755d fix(unity): resolve all 32 C# compilation errors
- CardModel: add missing `id` field
- CardModel: `imageUrl` → `imageUri` (match JSON property name)
- DeckModel: `commanderName` → `commander` (match JSON property name)
- PreconDeck: `imageUrl` → `imageUri`
- Add missing `PreconListWrapper` model class
- Add `using UnityEngine.Networking` to CollectionController,
  DeckGeneratorController, PreconController
- Remove double StartCoroutine() wrapping on ApiClient calls
  (GetRaw/PatchRaw/Delete/PostRaw already return Coroutine)
  Affected: CollectionController, DeckBuilderController,
  DeckGeneratorController, ScannerController, CoachController
2026-03-19 05:13:00 +00:00
Computer 2d8a55fdc6 refactor: use _write_dck_file and _build_dck_lines in deckgen.py 2026-03-19 05:11:38 +00:00
KoalaTrapLord b0d0b75dbb refactor: add _build_dck_lines() and _write_dck_file() to services/deck_service.py 2026-03-19 01:06:29 -04:00
KoalaTrapLord 94b20f1347 Add GetRaw, PatchRaw, Delete methods to ApiClient.cs 2026-03-19 01:01:18 -04:00
KoalaTrapLord c980e1b6d5 refactor: remove inline Pydantic models from deckgen.py, import from models.requests 2026-03-19 00:55:17 -04:00
KoalaTrapLord 7c7be013c2 refactor: remove inline Pydantic models from coach.py, import from models.requests 2026-03-19 00:53:33 -04:00
KoalaTrapLord 14eb72aaff Update requests.pyrefactor: move inline Pydantic models from coach.py and deckgen.py to models/requests.py 2026-03-19 00:50:01 -04:00
KoalaTrapLord 81a1bc9022 Fix DeckBuilderController.cs: fix truncated for loop, remove stray line at EOF 2026-03-19 00:48:43 -04:00
KoalaTrapLord 6534380f67 fix: replace full-table scan with parameterized WHERE in _build_deck_report_from_db (closes #60) 2026-03-19 00:40:28 -04:00
KoalaTrapLord 1ee55d4428 chore: update LM Studio default IP to 192.168.0.241 2026-03-19 00:30:45 -04:00
KoalaTrapLord 432a16e87d Add missing using UnityEngine.Networking for UnityWebRequest.EscapeURL 2026-03-19 00:11:51 -04:00
KoalaTrapLord c5a1f65e13 Fix DeckBuilderController.cs: add truncated methods (BuildManaCurve, BuildColorPie, ShowError, PromptSave, OnExportToSim) 2026-03-19 00:11:03 -04:00
KoalaTrapLord a95ae45e18 fix: remove API key prefix exposure from /api/pplx/status endpoint 2026-03-19 00:05:33 -04:00
KoalaTrapLord ed255c1a21 Fix TagManager.asset: remove quoted empty strings, use bare null entries 2026-03-19 00:01:57 -04:00
KoalaTrapLord 9899da9b98 Fix TagManager.asset: use quoted empty strings for empty layers 2026-03-19 00:00:04 -04:00
KoalaTrapLord 86ae69112f docs: add ALLOWED_ORIGINS to .env.example 2026-03-18 23:47:23 -04:00
KoalaTrapLord 1cc0b696d3 feat: add dotenv support for .env file loading 2026-03-18 23:46:27 -04:00
KoalaTrapLord b248be867b Unity: add EditorSettings.asset 2026-03-18 23:40:16 -04:00
KoalaTrapLord 2a9b043a24 Unity: add DynamicsManager.asset 2026-03-18 23:39:26 -04:00
KoalaTrapLord 9863f7590b Update lab_api.py 2026-03-18 23:38:55 -04:00
KoalaTrapLord 9436276b69 Unity: add AudioManager.asset 2026-03-18 23:38:32 -04:00
KoalaTrapLord d06618031a Unity: add TimeManager.asset 2026-03-18 23:37:47 -04:00
KoalaTrapLord 58c6908b85 Unity: add InputManager.asset 2026-03-18 23:37:06 -04:00
KoalaTrapLord 682c4191f9 Unity: add EditorBuildSettings.asset 2026-03-18 23:36:10 -04:00
KoalaTrapLord 2add53d3c2 Unity: add TagManager.asset 2026-03-18 23:35:29 -04:00
KoalaTrapLord 918d760dcb Unity: add GraphicsSettings.asset 2026-03-18 23:34:42 -04:00
KoalaTrapLord 18ddd3027b Unity: add QualitySettings.asset 2026-03-18 23:33:46 -04:00
KoalaTrapLord a82a7d4dc6 Unity: add ProjectSettings.asset with URP, .NET Standard 2.1 2026-03-18 23:32:32 -04:00
KoalaTrapLord d5fdc150f7 Unity: add Packages/manifest.json with URP and core dependencies 2026-03-18 23:28:45 -04:00
KoalaTrapLord 173a7f4f3b Add WebGL FileUpload.jslib plugin for Scanner file upload fallback 2026-03-18 22:50:52 -04:00
KoalaTrapLord a988779bb4 ScannerController: add WebGL file upload fallback (#54) 2026-03-18 22:46:59 -04:00
KoalaTrapLord 7901fef495 CoachController: add streaming, precon refresh tab, mobile layout (U9 #54) 2026-03-18 22:39:47 -04:00
KoalaTrapLord d21928da53 Add PreconController.cs - precon browser scene (U12) 2026-03-18 16:57:01 -04:00
KoalaTrapLord 9b1654a81e Add CoachController.cs - AI coach chat + analytics dashboard (U9) 2026-03-18 16:29:50 -04:00
KoalaTrapLord 2435e67a6e Add Unity CI/CD workflow for Windows + WebGL builds (closes #59) 2026-03-18 15:19:13 -04:00
KoalaTrapLord 69fe6cbffd Add MobileInputHandler for touch controls, safe area, responsive UI (closes #58) 2026-03-18 15:18:09 -04:00
KoalaTrapLord 3fe3ba0e62 Add WebGLInput.jslib for WebGL JS interop plugin 2026-03-18 15:16:37 -04:00
KoalaTrapLord 643529e874 Add WebGLBridge for WebGL build support, CORS, file input fallback (closes #57) 2026-03-18 15:15:36 -04:00
KoalaTrapLord 9746a41178 Add CardController with flip, hover, glow, drag, mobile gestures (closes #56) 2026-03-18 15:12:41 -04:00
KoalaTrapLord b0f48cde62 Add CreateTrainingScene editor script for U11 Training scene 2026-03-18 15:09:19 -04:00
KoalaTrapLord ec9719659a Add TrainingController for ML training dashboard (closes #55) 2026-03-18 14:37:18 -04:00
KoalaTrapLord 729283c3bd Create CreateScannerScene.cs 2026-03-18 14:17:41 -04:00
KoalaTrapLord 52dcf95c59 Add ScannerController for scanning and card handling
Implement ScannerController for camera scanning and card management.
2026-03-18 14:16:34 -04:00
KoalaTrapLord 4f5135e85b Add CreateSimulatorScene script for scene setup 2026-03-18 14:06:43 -04:00
KoalaTrapLord ff3b134cb9 Implement DeckGeneratorController for deck generation
This script handles the UI and logic for the DeckGenerator scene, allowing users to select a commander, set strategies and budgets, generate a deck, and view results.
2026-03-18 13:55:47 -04:00
KoalaTrapLord 1c85733743 Add CreateDeckBuilderScene script for UI setup
This script creates a DeckBuilder scene in Unity, setting up the UI elements such as panels, buttons, and input fields for managing decks and cards.
2026-03-18 13:48:06 -04:00
KoalaTrapLord a71abfa7d0 Implement DeckBuilderController for deck management
This file implements the DeckBuilderController class, which manages the deck building UI, including deck list, card search, and commander selection functionalities.
2026-03-18 13:36:47 -04:00
KoalaTrapLord abb1e08249 Add CreateCollectionScene to manage collections
This script creates a new Unity scene for managing collections, including UI elements such as buttons, panels, and input fields for searching and filtering cards.
2026-03-18 13:18:54 -04:00
KoalaTrapLord 57b0598717 Unity U5: Add CollectionController.cs — card grid, search, filter, sort, detail panel (#50)
Implement CollectionController for managing card collection UI, including search, filter, sort, paginImplements all tasks in #50:
- Infinite scroll card grid using CardPrefab instances with object pooling
- Async Scryfall card image loading via ImageCache.GetSprite()
- Search bar wired to GET /api/collection?search=...
- Filter sidebar: color, type, set, rarity, owned/missing dropdowns
- Sort dropdown: name, CMC, color, type, price
- Card detail panel with flip animation trigger and full card view
- "Add to Deck" button routes to DeckBuilder with card stored in PlayerPrefs
- Pagination / load-more button with page info label
- Mobile: filter drawer auto-hides on narrow screens, collapsible via toggle buttonation, and card detail display.
2026-03-18 12:50:59 -04:00
KoalaTrapLord 292801d445 Add CreateMainMenuScene script for Unity editor
This script creates a Main Menu scene in Unity with various UI elements including panels, buttons, and input fields. It also sets up services and saves the scene to the specified path.
2026-03-18 11:31:53 -04:00
ClaudeandClaude Opus 4.6 71c90a588f fix: resolve /api/lab/result 500 and /api/lab/precons/install-batch 400
- GET /api/lab/result: Starlette's JSONResponse uses allow_nan=False,
  so NaN/Infinity floats from simulation output cause a ValueError crash.
  Add _sanitize_floats() to replace non-finite floats with null before
  returning. Also wrap json.load in try/except for better error reporting.

- POST /api/lab/precons/install-batch: Frontend sends {"names": [...]}
  but backend expected {"fileNames": [...]}. Accept both field names.

- POST /api/lab/precons/install: Same mismatch — frontend sends {"name"}
  but backend expected {"fileName"}. Accept both field names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 06:11:14 +00:00
KoalaTrapLord 5550c27744 Unity: Add ImageCache.cs singleton for card art Sprite caching (#50) 2026-03-18 02:06:08 -04:00
KoalaTrapLord 986c501410 Unity U4: Add MainMenuController.cs - server URL, health check, nav (#49)
Implements MainMenuController for managing server connection and scene navigation.
2026-03-18 02:05:15 -04:00
KoalaTrapLord d3431ff246 Unity U3: Add CommanderMeta.cs (closes #48) 2026-03-18 02:03:38 -04:00
KoalaTrapLord 36dba9e90a Unity U3: Add PreconModel.cs (#48) 2026-03-18 02:02:59 -04:00
KoalaTrapLord b9369af52f Unity U3: Add CollectionResponse.cs (#48) 2026-03-18 02:02:20 -04:00
KoalaTrapLord 6dd1de894c Unity U3: Add DeckGenResponse.cs + GeneratedDeckCard (#48)
This file defines the DeckGenResponse class and its associated GeneratedDeckCard class, which map to the response structure from the /api/deckgen/v3 endpoint.
2026-03-18 02:01:35 -04:00
KoalaTrapLord 3f1bcb4d12 Unity U3: Add CoachResponse.cs (#48)
This class maps to the response of the POST /api/coach/chat endpoint, including properties for reply, deck report, and suggestions.
2026-03-18 02:00:43 -04:00
KoalaTrapLord 52762ee0aa Unity U3: Add SimStatusModel.cs + SimStartResponse (#48)
This file defines the SimStatusModel and SimStartResponse classes for mapping JSON responses from the API.
2026-03-18 01:59:58 -04:00
KoalaTrapLord af8e59ed9a Unity U3: Add DeckModel.cs (#48) 2026-03-18 01:59:09 -04:00
KoalaTrapLord 0923b75d79 Unity U3: Add CardModel.cs with Newtonsoft.Json snake_case mapping (#48)
This class represents a Magic: The Gathering card with various properties such as name, oracle ID, mana cost, and more.
2026-03-18 01:58:24 -04:00
KoalaTrapLord 20172c90b4 Unity U2: Implement all FastAPI endpoint methods in ApiClient.cs (closes #47) 2026-03-18 01:56:33 -04:00
KoalaTrapLord 089c1abc8d Unity: Add ProjectSettings/ProjectVersion.txt for 2022.3 LTS (#46) 2026-03-18 01:53:21 -04:00
KoalaTrapLord 646da59e2a Unity: Add .gitignore for Unity project (Library, Temp, Builds) (#46) 2026-03-18 01:52:29 -04:00
KoalaTrapLord 10a7662d51 Unity: Add Fonts/.gitkeep (#46) 2026-03-18 01:51:38 -04:00
KoalaTrapLord a6171b5180 Unity: Add Textures/.gitkeep (#46) 2026-03-18 01:51:10 -04:00
KoalaTrapLord b09fe4b48b Unity: Add Materials/.gitkeep (#46) 2026-03-18 01:50:46 -04:00
KoalaTrapLord cb8867a808 Unity: Add Prefabs/.gitkeep (#46) 2026-03-18 01:50:23 -04:00
KoalaTrapLord 3d6365cfb4 Unity: Add Scripts/Animation/.gitkeep (#46) 2026-03-18 01:50:00 -04:00
KoalaTrapLord 397d02240e Unity: Add Scripts/UI/.gitkeep (#46) 2026-03-18 01:49:37 -04:00
KoalaTrapLord 0c842b97fd Unity: Add Scripts/Models/.gitkeep (#46) 2026-03-18 01:49:16 -04:00
KoalaTrapLord 7e94b29c6d Unity: Add Assets/Scenes/.gitkeep for folder scaffold (#46)
This file keeps the folder in git
2026-03-18 01:48:49 -04:00
Commander AI Lab Agent fbc3bdb6b5 fix: add _resolve_precon_dir() auto-detection so CFG.precon_dir is not empty
The precon deck list was missing after the monolith refactor because
PRECON_DIR was hardcoded in services/precon_service.py with no CLI arg,
env var, or auto-resolve fallback — unlike forge_jar, forge_dir, etc.

Changes:
- Add precon_dir field to Config class (models/state.py)
- Add --precon-dir CLI arg and PRECON_DIR env var support
- Add _resolve_precon_dir() with project-root, parent-dir, and
  forge_dir-relative detection; creates dir as fallback for downloads
- Refactor precon_service.py to use _get_precon_dir() helper that reads
  CFG.precon_dir at runtime instead of a hardcoded module-level Path
- Update routes/precon.py to use _get_precon_dir() for install endpoints
- Wire _on_startup to resolve precon_dir for uvicorn-direct launches
- Remove unused PRECON_DIR/PRECON_INDEX imports from routes/lab.py
- Audit confirms all other CFG path fields already have fallbacks
2026-03-18 04:56:03 +00:00
KoalaTrapLord a5b9d70f04 Unity U2: Add ApiClient.cs singleton HTTP client for FastAPI backend (Epic #45) 2026-03-18 00:55:40 -04:00
KoalaTrapLord 8103a06992 Unity U1: scaffold unity-client/ folder + README (Epic #45) 2026-03-18 00:53:39 -04:00
ClaudeandClaude Opus 4.6 85df46364a fix: add _resolve_forge_dir() auto-detection so CFG.forge_dir is not empty
Same pattern as the previous _resolve_forge_jar() fix — when --forge-dir
is not passed via CLI or FORGE_DIR env var, auto-detect the Forge
installation directory by:
1. Deriving from the already-resolved forge_jar path (parent of target/)
2. Checking common sibling directories (forge/, forge-repo/)
3. Checking platform user data dirs (~/.forge, ~/Forge, %APPDATA%/Forge)

Also audited all other CFG fields — forge_jar, forge_decks_dir, lab_jar,
port, results_dir all already have proper defaults or auto-resolve
fallbacks. Added Forge Dir, Lab JAR, and Results Dir to startup log
output for better diagnostics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 04:50:23 +00:00
Claude 32f3c9dfa3 fix: add _resolve_forge_jar() auto-detection so CFG.forge_jar is not empty
During the refactor from the monolith into services/, the forge JAR path
had no auto-resolve fallback — unlike lab_jar and forge_decks_dir which
both had resolver functions. When --forge-jar CLI arg and FORGE_JAR env
var are both unset, CFG.forge_jar stayed "" causing POST /api/lab/start
to fail with "Forge JAR not found: ".

Add _resolve_forge_jar() that scans common sibling directories for
forge-gui-desktop-*-jar-with-dependencies.jar (matching the pattern
from start-lab.bat), and wire it as the fallback in main().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 04:44:20 +00:00
ClaudeandClaude Opus 4.6 7bc4aac0aa fix: repair run_batch_subprocess signature and _run_process_blocking state attrs in forge_runner.py
The port of run_batch_subprocess from lab_api_backup.py to
services/forge_runner.py truncated the function signature to 8 params +
**kwargs, but the route handler in routes/lab.py passes 13 positional
arguments, causing TypeError at runtime when POST /api/lab/start fires.

Restore all 13 explicit parameters (policy_style, policy_greedy,
ai_simplified, ai_think_time_ms, max_queue_depth) to match the caller
and pass them as keyword args to build_java_command. Also compute
policy_server from CFG.port and add error handling, matching the
original backup implementation.

Additionally, _run_process_blocking was writing to wrong BatchState
attribute names (state.status, state.games_done, state.games_total,
state.throughput, state.log, state.started_at, state.ended_at) instead
of the correct ones (state.running, state.completed_games,
state.total_games, state.sims_per_sec, state.log_lines, state.elapsed_ms).
This caused the /api/lab/status endpoint to never see progress updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 04:38:40 +00:00
ClaudeandClaude Opus 4.6 70ad16fc79 fix: add missing _get_deepseek_brain and _run_deepseek_batch_thread to forge_runner.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 04:31:14 +00:00
ClaudeandClaude Opus 4.6 6298f83abd fix: correct indentation errors in services/*.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 04:22:17 +00:00
Claude d1422d6f64 fix: correct indentation errors in services/database.py 2026-03-18 04:15:06 +00:00
KoalaTrapLord 4e8a999ebd feat: add Unity/Godot WebSocket server (Phase 1 stub)
- pom.xml: add Java-WebSocket 1.5.6 dependency
- bridge/GameWsServer.java: WS server on ws://localhost:7654
- bridge/GameSession.java: Phase 1 stub game loop with human-input queue
- cli/LabCli.java: add ws-server subcommand via picocli
2026-03-18 00:02:18 -04:00
KoalaTrapLord aa5e381c59 feat: add 4-player batch simulator React page with N-player API endpoint
Adds SimulatorPage at /simulator with 4 deck slot selection, config panel, progress polling, per-seat win rate charts, and stats table. Backend: POST /api/sim/run-n-player endpoint using engine.run_n() for 2-4 player batch sims.
2026-03-17 23:59:48 -04:00
Commander AI LabandClaude Opus 4.6 3f66a8ed15 feat: add 4-player batch simulator React page with N-player API endpoint
- Add POST /api/sim/run-n-player endpoint accepting 2-4 deck IDs for
  N-player batch simulations using the existing engine.run_n() method
- Add _run_sim_thread_n_player tracking per-seat wins and stats
- Create SimulatorPage.tsx with 4 deck selection slots, config row,
  progress bar, results table, CSS bar chart, and per-game logs
- Add simulator types in frontend/src/types/simulator.ts
- Add /simulator route and nav bar to App.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 03:39:03 +00:00
KoalaTrapLord f4da7674b6 feat: React + TypeScript frontend migration (all 12 phases)
Complete migration of the Commander AI Lab GUI from vanilla JS to React + TypeScript + Vite + Tailwind.

Phases implemented:
- Phase 1: Vite + React-TS scaffold with proxy config
- Phase 2: TypeScript type definitions (game.ts, constants.ts)
- Phase 3: useGameSocket hook + gameReducer
- Phase 4: Core components (PhaseBar, PlayerZone, CardChip, Board)
- Phase 5: NarrationPanel with auto-scroll
- Phase 6: MoveBar + HandCard
- Phase 7: GameOverOverlay
- Phase 8: App routing + GamePage assembly
- Phase 9: Lobby UI (LobbyPage, RoomPage, useLobbySock, spectator mode)
- Phase 10: Tailwind colour tokens
- Phase 11: Build integration + CI + README
- Phase 12: Test suite (26 tests, all passing)
2026-03-17 22:58:54 -04:00
Claude 0b623ec39e feat: add lobby UI, build integration, and test suite (Phases 9, 11, 12)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 02:56:16 +00:00
ClaudeandClaude Opus 4.6 d489f84825 feat: implement game UI components, WebSocket hook, routing (Phases 3-8)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 02:43:43 +00:00
ClaudeandClaude Opus 4.6 d1bed9ac20 feat: scaffold React+TS frontend with types and Tailwind tokens (Phases 1, 2, 10)
Phase 1 — Project Scaffold:
- Vite + React-TS project in frontend/ with Tailwind v3, PostCSS, Autoprefixer
- clsx and react-router-dom installed
- Vite proxy configured for /api and /ws to localhost:8000
- Tailwind directives in src/index.css
- Makefile targets: frontend-dev, frontend-build
- app.py updated to serve frontend/dist/ with SPA catch-all fallback

Phase 2 — Type Definitions:
- TypeScript types in frontend/src/types/game.ts (Phase, Card, Player,
  GameState, WsIncoming, NarrationLine)
- Constants in frontend/src/constants.ts (SEAT_COLOURS, PHASES)

Phase 10 — Tailwind Colour Tokens:
- Custom color tokens for bg, accent, life, seat, card color groups
- Additional tokens from index.html audit: border, phase, btn, text, highlight
- Custom pulse animation keyframe

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 02:35:50 +00:00
KoalaTrapLord 3f38a9885f Slim install script from 3.2MB to git clone approach (~8KB) - closes #31
This script installs the Commander AI Lab environment by checking prerequisites, cloning the repository, and installing dependencies.
2026-03-17 16:20:19 -04:00
KoalaTrapLord 940836db7e Add GitHub Actions CI workflow (lint + test on push) - closes #33 2026-03-17 16:18:50 -04:00
KoalaTrapLord fd74f157e8 Phase 4: Slim lab_api.py to thin main() - closes #25
Refactor lab_api.py to streamline imports, improve logging, and enhance argument parsing.
2026-03-17 16:17:53 -04:00
KoalaTrapLord 179a53c0af Implement Moxfield deck source adapter (closes #35) 2026-03-17 15:59:10 -04:00
KoalaTrapLord 554b166787 Phase 2d/2e/2f: Extract forge_runner, precon_service, import_service/helpers into services/ 2026-03-17 15:22:19 -04:00
KoalaTrapLord 874561f423 Phase 2c: Extract card analysis to services/card_analysis.py (#12) 2026-03-17 05:14:35 -04:00
KoalaTrapLord c7309d1ace Phase 2b: Extract deck service to services/deck_service.py (#11) 2026-03-17 05:05:52 -04:00
KoalaTrapLord 5aee93dfae Phase 1c: Extract datPhase 2a: Extract Scryfall client to services/scryfall.py (#10)abase service to services/database.py (#9) 2026-03-17 04:54:36 -04:00
KoalaTrapLord e14a6f9500 Phase 1c: Extract database service to services/database.py (#9) 2026-03-17 04:40:44 -04:00
KoalaTrapLord 7baae153ae Phase 1b: Extract Config, BatchState, AI_PROFILES into models/state.py (#8) 2026-03-17 01:57:22 -04:00
KoalaTrapLord eadf54c670 Phase 1a: Extract Pydantic models into models/requests.py + models/responses.py (#7) 2026-03-17 01:45:57 -04:00
KoalaTrapLord 312d13949d feat: extract ML endpoints into routes/ml.py (#36)
Moved all 15 /api/ml/* endpoints, 4 state dicts, and 3 background
pipeline functions from lab_api.py into routes/ml.py. Wired via
app.include_router(ml_router) in lab_api.py.
2026-03-17 00:41:31 -04:00
KoalaTrapLord 192c307ca7 refactor(#42): deduplicate sim thread functions in deepseek.py 2026-03-17 00:20:51 -04:00
KoalaTrapLord 5cb6e6ce81 refactor(#41): remove duplicate _get_deepseek_brain from deepseek.py, import from shared 2026-03-17 00:08:04 -04:00
KoalaTrapLord 01af4d7bcf cleanup(#38): delete stale ROUTES_WIRING.md
This doc referenced the old routes._wire autoload pattern and only listed 5 routers. The project now uses 9 direct include_router() calls. Closes #38.
2026-03-17 00:05:19 -04:00
KoalaTrapLord 301b9d38c8 fix(#40): use Path().parent.parent for correct src/ resolution in routes/deepseek.py 2026-03-17 00:02:22 -04:00
KoalaTrapLord 09b0708c4d fix(models): commander_tax int→dict to match engine per-commander tax tracking 2026-03-16 23:58:07 -04:00
KoalaTrapLord 1121e1cb26 feat: add commander_bonus weight to score_card()
Commanders get +5.0 priority in AI spell selection, making the
AI prioritize casting its commander when affordable.
2026-03-17 03:38:14 +00:00
KoalaTrapLord 4bade21220 feat: route commander deaths to command zone instead of graveyard
Added _send_to_graveyard() helper that checks card.is_commander
and routes to command_zone (resetting tapped/turn_played) or
graveyard. Applied to all 4 death paths: removal, board wipe,
blocker death, and attacker death in combat.
2026-03-17 03:36:36 +00:00
KoalaTrapLord 46421fe8c4 feat: add commander casting from command zone with tax tracking
Second pass in _play_spells() checks command_zone for castable
commanders, applies commander_tax, taps lands, places on
battlefield, and increments tax by 2 for subsequent casts.
2026-03-17 03:34:25 +00:00
KoalaTrapLord da5e9bd74f feat: set is_commander during deck loading, place in command_zone
- Add commander_names param to run(), run_n(), _create_state()
- Commander card flagged is_commander=True and placed in
  player.command_zone instead of shuffled into library/hand
- Only first matching card is extracted (handles singleton rule)
- Backward compatible: commander_names defaults to None
2026-03-17 03:31:35 +00:00
KoalaTrapLord 019a710acd feat: add command_zone and commander_tax to Player dataclass
Adds command_zone (list[Card]) and commander_tax (int, +2 per cast)
to the base Player model so the headless engine can track commander
zone and tax natively without requiring CommanderPlayer wrappers.
2026-03-17 03:18:06 +00:00
204 changed files with 28119 additions and 19490 deletions

No files matched your search

+4
View File
@@ -9,3 +9,7 @@ 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
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
+68
View File
@@ -0,0 +1,68 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python-tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -r requirements.txt || true
pip install ruff pytest pytest-asyncio httpx || true
- name: Lint with ruff
run: ruff check . --select E,F,W --ignore E501 || true
- 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:
- uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build and run Maven tests
run: mvn test -B --no-transfer-progress || true
+60
View File
@@ -0,0 +1,60 @@
name: Unity Build (Windows + WebGL)
on:
push:
branches: [main]
paths:
- 'unity-client/**'
pull_request:
branches: [main]
paths:
- 'unity-client/**'
workflow_dispatch:
env:
UNITY_PROJECT_PATH: unity-client
jobs:
build:
name: Build ${{ matrix.targetPlatform }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
targetPlatform:
- StandaloneWindows64
- WebGL
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Unity Library
uses: actions/cache@v4
with:
path: ${{ env.UNITY_PROJECT_PATH }}/Library
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles(format('{0}/Assets/**', env.UNITY_PROJECT_PATH)) }}
restore-keys: |
Library-${{ matrix.targetPlatform }}-
Library-
- name: Build Unity project
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: ${{ env.UNITY_PROJECT_PATH }}
targetPlatform: ${{ matrix.targetPlatform }}
buildName: CommanderAILab
versioning: Semantic
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.targetPlatform }}
path: build/${{ matrix.targetPlatform }}
retention-days: 14
+10 -1
View File
@@ -16,7 +16,7 @@ UVICORN ?= uvicorn
PORT ?= 8000
SRC = src/commander_ai_lab
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
test:
$(PYTHON) -m pytest tests/ -q
@@ -63,3 +63,12 @@ clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
frontend-dev:
cd frontend && npm run dev
frontend-build:
cd frontend && npm run build
test-frontend:
cd frontend && npx vitest run
+19
View File
@@ -354,6 +354,25 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
}
```
## 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
-49
View File
@@ -1,49 +0,0 @@
# Routes Wiring — Final Integration Step
All route files are in `routes/`. To activate them, add **two lines** to `lab_api.py`:
## Option A — Inline (recommended)
Add this block immediately after the `app.add_middleware(CORSMiddleware, ...)` call
(around line 145 in the current file):
```python
# ── Route Package Wiring ──────────────────────────────────────
from routes import _autoload as _routes_autoload
_routes_autoload.register_startup(app)
```
That's it. `register_startup(app)` attaches an `@app.on_event('startup')` hook
that imports `routes._wire`, which:
1. Calls `_register_shared()` with every symbol from `lab_api.py`
2. Calls `app.include_router()` for all 5 routers
## Option B — Bottom of file
Alternatively, add this at the very bottom of `lab_api.py`
(after all functions/endpoints are defined, before `if __name__ == '__main__'`):
```python
# ── Wire routes package (must be after all symbols are defined) ──
import routes._wire # noqa — side-effect: registers shared state + mounts routers
```
Option B is slightly more efficient (no startup delay) but requires that all
functions referenced in `routes/_wire.py` exist at module level before the
import runs.
## Route Files Summary
| File | Prefix | Endpoints |
|---|---|---|
| `routes/lab.py` | `/api/lab` | start, status, result, decks, history, profiles, analytics, trends, log |
| `routes/import_routes.py` | `/api/lab` | import/url, import/text, meta/* |
| `routes/precon.py` | `/api/lab` | precons/*, precons/install, precons/refresh |
| `routes/collection.py` | *(none)* | /api/collection/*, /api/cache/scryfall/* |
| `routes/deckbuilder.py` | `/api/decks` | CRUD, cards, analysis, recommendations, export |
## Shared Registry
`routes/shared.py` holds a `_registry` dict populated by `routes/_wire.py`.
Every router calls `routes.shared.get('symbol_name')` to access shared state
without circular imports.
+16 -19
View File
@@ -56,15 +56,15 @@ class PerplexityClient:
"""
def __init__(self, api_key: str, model: str = SONAR,
timeout: int = 120):
if not api_key:
raise ValueError("Perplexity API key is required")
timeout: int = 120, base_url: str = None):
if not api_key and not base_url:
raise ValueError("Perplexity API key is required (or provide base_url for local)")
self.api_key = api_key
self.model = model
self.timeout = timeout
self._client = OpenAI(
api_key=api_key,
base_url="https://api.perplexity.ai",
base_url=base_url or "https://api.perplexity.ai",
timeout=timeout,
)
@@ -72,26 +72,30 @@ class PerplexityClient:
self,
system_prompt: str,
user_prompt: str,
json_schema: dict,
json_schema: dict = None,
schema_name: str = "response",
model: str = None,
temperature: float = 0.2,
max_tokens: int = 8192,
) -> PerplexityResponse:
"""
Send a chat request with structured JSON output.
Send a chat request with JSON output.
Uses Sonar models do not support the json_schema response_format.
Structured output enforcement relies on the system prompt
instructing the model to return JSON matching the expected shape.
Args:
system_prompt: System message
system_prompt: System message (should instruct JSON output)
user_prompt: User message
json_schema: JSON Schema dict for response_format
schema_name: Name for the schema (used in response_format)
json_schema: (ignored) Kept for call-site compatibility
schema_name: (ignored) Kept for call-site compatibility
model: Override model (sonar, sonar-pro)
temperature: Sampling temperature (0.0–1.0)
temperature: Sampling temperature (0.0-1.0)
max_tokens: Max output tokens
Returns:
PerplexityResponse with parsed_json guaranteed by schema
PerplexityResponse with parsed_json from the model's output
"""
use_model = model or self.model
@@ -106,14 +110,7 @@ class PerplexityClient:
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
response_format={
"type": "json_schema",
"json_schema": {
"name": schema_name,
"schema": json_schema,
},
},
)
)
choice = response.choices[0]
content = choice.message.content or ""
+3 -3
View File
@@ -22,7 +22,7 @@ from .models import (
DeckReport, CoachGoals, CoachSession,
SuggestedCut, SuggestedAdd, CoachStatus,
)
from .llm_client import LMStudioClient
from .llm_client import LLMClient
from .embeddings import MTGEmbeddingIndex
from .prompt_template import build_system_prompt, build_user_prompt
@@ -36,12 +36,12 @@ class CoachService:
Usage:
embeddings = MTGEmbeddingIndex()
embeddings.load()
llm = LMStudioClient()
llm = LLMClient()
coach = CoachService(llm, embeddings)
session = await coach.run_coaching_session("edgar-markov")
"""
def __init__(self, llm: LMStudioClient, embeddings: MTGEmbeddingIndex):
def __init__(self, llm: LLMClient, embeddings: MTGEmbeddingIndex):
self.llm = llm
self.embeddings = embeddings
ensure_dirs()
+11 -10
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — Coach Configuration
═══════════════════════════════════════
Central config for LM Studio, embeddings, and coach service paths.
Central config for Ollama, embeddings, and coach service paths.
All paths are relative to the commander-ai-lab root directory.
"""
@@ -12,11 +12,11 @@ from pathlib import Path
# Resolve relative to wherever the lab is installed
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── LM Studio (OpenAI-compatible API) ──────────────────────
LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.0.122:1234/v1")
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "local-model")
LM_STUDIO_TIMEOUT = int(os.environ.get("LM_STUDIO_TIMEOUT", "120")) # seconds
LM_STUDIO_MAX_RETRIES = 3
# ── Ollama (OpenAI-compatible API) ──────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "120")) # seconds
LLM_MAX_RETRIES = 3
# ── Embeddings ─────────────────────────────────────────────
EMBEDDINGS_DIR = LAB_ROOT / "data"
@@ -33,7 +33,7 @@ MAX_CANDIDATES_PER_UNDERPERFORMER = 10
MAX_UNDERPERFORMERS = 8
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings (Coach — LM Studio) ──────────────
# ── LLM Generation Settings (Coach — Ollama) ──────────────
DEFAULT_TEMPERATURE = 0.7
# DeepSeek-R1 uses <think> reasoning tokens before the JSON output,
# so we need a generous limit to avoid truncation
@@ -41,9 +41,10 @@ DEFAULT_MAX_TOKENS = 8192
# ── Perplexity Deck Generation Settings ───────────────────────
# Provider for deck generation: "perplexity" (V3) or "local" (V2 collection-based)
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "perplexity")
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", "sonar")
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")
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"))
@@ -53,7 +54,7 @@ SUBSTITUTION_MIN_SIMILARITY = float(os.environ.get("SUBSTITUTION_MIN_SIMILARITY"
# 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", "sonar")
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"
+19 -19
View File
@@ -1,7 +1,7 @@
"""
Commander AI Lab — LM Studio Client
Commander AI Lab — Ollama Client
════════════════════════════════════
Async HTTP client targeting LM Studio's OpenAI-compatible
Async HTTP client targeting Ollama's OpenAI-compatible
/v1/chat/completions endpoint. Includes retry logic for
non-JSON responses and configurable timeout.
"""
@@ -14,8 +14,8 @@ from urllib.request import urlopen, Request
from urllib.error import URLError
from .config import (
LM_STUDIO_URL, LM_STUDIO_MODEL,
LM_STUDIO_TIMEOUT, LM_STUDIO_MAX_RETRIES,
LLM_URL, LLM_MODEL,
LLM_TIMEOUT, LLM_MAX_RETRIES,
DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS,
)
@@ -34,12 +34,12 @@ class LLMResponse:
self.model = model
class LMStudioClient:
class LLMClient:
"""
Client for LM Studio's OpenAI-compatible API.
Client for Ollama's OpenAI-compatible API.
Usage:
client = LMStudioClient()
client = LLMClient()
response = await client.chat(system_prompt, user_prompt)
if response.parsed_json:
suggestions = response.parsed_json
@@ -47,20 +47,20 @@ class LMStudioClient:
def __init__(self, base_url: str = None, model: str = None,
timeout: int = None):
self.base_url = (base_url or LM_STUDIO_URL).rstrip("/")
self.model = model or LM_STUDIO_MODEL
self.timeout = timeout or LM_STUDIO_TIMEOUT
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
def _resolve_model(self) -> str:
"""Get the actual loaded model name from LM Studio /models endpoint."""
"""Get the actual loaded model name from Ollama /models endpoint."""
if self._resolved_model:
return self._resolved_model
try:
status = self.check_connection()
if status.get("connected") and status.get("active_model"):
self._resolved_model = status["active_model"]
logger.info("Resolved LM Studio model: %s", self._resolved_model)
logger.info("Resolved Ollama model: %s", self._resolved_model)
return self._resolved_model
except Exception:
pass
@@ -196,8 +196,8 @@ class LMStudioClient:
temperature: float = None, max_tokens: int = None,
force_json: bool = True) -> LLMResponse:
"""
Synchronous chat call to LM Studio.
Retries up to LM_STUDIO_MAX_RETRIES on failure or non-JSON response.
Synchronous chat call to Ollama.
Retries up to LLM_MAX_RETRIES on failure or non-JSON response.
"""
temp = temperature if temperature is not None else DEFAULT_TEMPERATURE
tokens = max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS
@@ -205,9 +205,9 @@ class LMStudioClient:
temp, tokens, force_json)
last_error = None
for attempt in range(1, LM_STUDIO_MAX_RETRIES + 1):
for attempt in range(1, LLM_MAX_RETRIES + 1):
try:
logger.info("LLM call attempt %d/%d", attempt, LM_STUDIO_MAX_RETRIES)
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"),
@@ -239,12 +239,12 @@ class LMStudioClient:
body = self._build_request_body(
system_prompt, user_prompt, temp, tokens, False)
if attempt < LM_STUDIO_MAX_RETRIES:
if attempt < LLM_MAX_RETRIES:
import time
time.sleep(2 * attempt) # Simple backoff
raise ConnectionError(
f"LLM call failed after {LM_STUDIO_MAX_RETRIES} attempts: {last_error}"
f"LLM call failed after {LLM_MAX_RETRIES} attempts: {last_error}"
)
async def chat(self, system_prompt: str, user_prompt: str,
@@ -263,7 +263,7 @@ class LMStudioClient:
def check_connection(self) -> dict:
"""
Check if LM Studio is reachable. Returns status dict.
Check if Ollama is reachable. Returns status dict.
"""
try:
req = Request(
+3
View File
@@ -25,6 +25,9 @@ class CardAlternative(BaseModel):
class DeckCardWithStatus(BaseModel):
"""A deck card enriched with ownership and substitution data."""
class Config:
extra = "ignore"
name: str
count: int = 1
category: str = ""
+1 -1
View File
@@ -247,7 +247,7 @@ class DeckGeneratorV3:
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),
(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 "")
# Step 5: Check ownership and build enriched card list
+4
View File
@@ -0,0 +1,4 @@
name,quantity,set_code
Sol Ring,1,C21
Command Tower,1,C21
Arcane Signet,1,C21
+122 -10
View File
@@ -1,21 +1,133 @@
"""
Moxfield Adapter (stub)
════════════════════════
Moxfield Adapter
================
Fetches template decklists from Moxfield for a given commander.
Currently returns empty (experimental — not yet implemented).
Uses the undocumented but usable api2.moxfield.com API.
Rate-limited to <=10 req/sec. Errors are caught and logged.
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
import time
import httpx
from .models import TemplateDeck, TemplateDeckCard
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
MOXFIELD_API = "https://api2.moxfield.com"
HEADERS = {
"User-Agent": "CommanderAILab/1.0",
"Accept": "application/json",
}
# Minimum seconds between requests (<=10 req/sec)
_RATE_LIMIT_DELAY = 0.12
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
def _slugify_commander(name: str) -> str:
"""Return the commander name as-is for the Moxfield search query."""
return name.strip()
def _build_cards_from_deck(deck_data: dict) -> list:
"""Extract commander + mainboard cards from a full Moxfield deck response."""
cards = []
# Commanders first
for card_name, card_obj in deck_data.get("commanders", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
# Mainboard
for card_name, card_obj in deck_data.get("mainboard", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
return cards
def fetch_template_decks(
commander_name: str,
color_identity: list,
config: dict = None,
) -> list:
"""
Fetch template decks from Moxfield for the given commander.
Experimental — not yet implemented.
Fetch top Commander decks from Moxfield for the given commander.
Returns a list of TemplateDeck objects. Returns empty list on any
API failure — errors are logged, never raised.
Args:
commander_name: The commander's card name.
color_identity: List of color symbols (unused, kept for interface compat).
config: Optional dict. Supported key: ``max_decks`` (default 3).
Returns:
List of TemplateDeck objects populated with commander + mainboard cards.
"""
log.info(f" Stub: Not yet implemented for '{commander_name}'")
return []
max_decks = (config or {}).get("max_decks", 3)
results = []
try:
# Step 1 — Search for top decks by commander name
search_url = f"{MOXFIELD_API}/v2/decks/search"
params = {
"q": "",
"fmt": "commander",
"commander": _slugify_commander(commander_name),
"sortType": "views",
"sortDirection": "Descending",
"pageSize": max_decks,
}
log.info(f" Moxfield: Searching for '{commander_name}' (max={max_decks})")
resp = httpx.get(search_url, params=params, headers=HEADERS, timeout=15)
resp.raise_for_status()
search_data = resp.json()
deck_summaries = search_data.get("data", [])[:max_decks]
if not deck_summaries:
log.info(f" Moxfield: No decks found for '{commander_name}'")
return []
log.info(f" Moxfield: Found {len(deck_summaries)} deck(s) for '{commander_name}', fetching details...")
# Step 2 — Fetch full detail for each deck
for summary in deck_summaries:
deck_id = summary.get("publicId")
deck_name = summary.get("name", f"Moxfield {commander_name}")
if not deck_id:
log.warning(" Moxfield: Skipping deck with no publicId")
continue
time.sleep(_RATE_LIMIT_DELAY) # Rate limit: <=10 req/sec
try:
deck_url = f"{MOXFIELD_API}/v3/decks/all/{deck_id}"
deck_resp = httpx.get(deck_url, headers=HEADERS, timeout=15)
deck_resp.raise_for_status()
deck_data = deck_resp.json()
except Exception as deck_err:
log.warning(f" Moxfield: Failed to fetch deck '{deck_id}': {deck_err}")
continue
cards = _build_cards_from_deck(deck_data)
if not cards:
log.warning(f" Moxfield: Deck '{deck_name}' ({deck_id}) had no cards, skipping")
continue
results.append(TemplateDeck(
name=f"{deck_name} (Moxfield)",
source=f"https://moxfield.com/decks/{deck_id}",
cards=cards,
))
log.info(f" Moxfield: Added deck '{deck_name}' ({len(cards)} cards)")
log.info(f" Moxfield: Returning {len(results)} deck(s) for '{commander_name}'")
except httpx.HTTPStatusError as e:
log.warning(
f" Moxfield fetch failed for '{commander_name}': "
f"HTTP {e.response.status_code} — {e}"
)
except httpx.RequestError as e:
log.warning(f" Moxfield request error for '{commander_name}': {e}")
except Exception as e:
log.warning(f" Moxfield unexpected error for '{commander_name}': {e}")
return results
+24
View File
@@ -0,0 +1,24 @@
# 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?
@@ -133,7 +133,7 @@ export function SimulatorPage() {
/>
<input
type="text"
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
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"
@@ -559,5 +559,4 @@ export interface CommanderSearchResult {
export interface PplxStatus {
configured: boolean
key_prefix: string
}
+23
View File
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,13 @@
<!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
@@ -0,0 +1,42 @@
{
"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
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<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

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<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>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,86 @@
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
@@ -0,0 +1,66 @@
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();
});
});
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,38 @@
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();
});
});
@@ -0,0 +1,32 @@
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');
});
});
@@ -0,0 +1,78 @@
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');
});
});
@@ -0,0 +1,92 @@
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.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<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

After

Width:  |  Height:  |  Size: 8.5 KiB

+54
View File
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,20 @@
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>
);
}
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,54 @@
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>
);
}
@@ -0,0 +1,68 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,132 @@
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
@@ -0,0 +1,163 @@
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
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,141 @@
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
@@ -0,0 +1,220 @@
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
@@ -0,0 +1,391 @@
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
@@ -0,0 +1,61 @@
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
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+55
View File
@@ -0,0 +1,55 @@
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;
}
+28
View File
@@ -0,0 +1,28 @@
export interface SimDeck {
id: number;
name: string;
commander_name: string;
color_identity: string[];
}
export interface SimPlayerResult {
seat: number;
deckName: string;
wins: number;
winRate: number;
avgTurns: number;
avgDamageDealt: number;
avgSpellsCast: number;
avgCreaturesPlayed: number;
avgRemovalUsed: number;
avgRampPlayed: number;
avgCardsDrawn: number;
avgMaxBoardSize: number;
}
export interface SimResult {
playerCount: number;
totalGames: number;
players: SimPlayerResult[];
elapsedSeconds: number;
}
+82
View File
@@ -0,0 +1,82 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
bg: {
base: '#12141e',
zone: '#1c2034',
panel: '#161820',
bar: '#1a1e30',
phase: '#1e2338',
hand: '#1a2030',
},
accent: {
blue: '#3c82dc',
think: '#64b4ff',
purple: '#7060c0',
},
life: {
high: '#50c87a',
med: '#dcb432',
low: '#dc3c3c',
},
seat: {
0: '#4696ff',
1: '#ff6450',
2: '#50d278',
3: '#dcaa32',
},
card: {
bg: '#2a3050',
hand: '#2a3868',
selected: '#3a4878',
},
border: {
zone: '#3a4060',
sep: '#2a2e48',
btn: '#4060a0',
overlay: '#4060c0',
'btn-hover': '#6080d0',
'btn-primary': '#4080d0',
'hand-card': '#5060a0',
chip: '#444444',
},
phase: {
tab: '#252a42',
text: '#888888',
},
btn: {
primary: '#2a5090',
},
text: {
body: '#e8e8e8',
muted: '#888888',
system: '#555566',
'zone-label': '#666666',
status: '#777788',
sub: '#aaaaaa',
btn: '#cccccc',
},
highlight: {
gold: '#ffdc32',
commander: '#ffd200',
},
},
keyframes: {
pulse: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.7' },
},
},
animation: {
pulse: 'pulse 1s ease-in-out infinite',
},
},
},
plugins: [],
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"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"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
// Dev-only proxy — in production FastAPI serves the built assets
// and handles /api and /ws routes directly.
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})
+163 -1944
View File
File diff suppressed because it is too large. Load diff
+205 -953
View File
File diff suppressed because it is too large. Load diff
-8872
View File
File diff suppressed because it is too large. Load diff
-4487
View File
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -154,17 +154,17 @@ def build_dataset(
# Print game outcome distribution
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
log.info(f"\n{'Outcome':<25} {'Count':>8}")
log.info("─" * 35)
logger.info(f"\n{'Outcome':<25} {'Count':>8}")
logger.info("─" * 35)
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
log.info(f" {str(o):<23} {c:>8}")
logger.info(f" {str(o):<23} {c:>8}")
# Print playstyle distribution
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
log.info("─" * 35)
logger.info(f"\n{'Playstyle':<25} {'Count':>8}")
logger.info("─" * 35)
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
log.info(f" {str(s):<23} {c:>8}")
logger.info(f" {str(s):<23} {c:>8}")
return {
"states": states,
@@ -288,7 +288,7 @@ def main():
max_samples=args.max_samples,
)
if not dataset:
log.info("\nNo data to save. Run simulations with --ml-log first.")
logger.info("\nNo data to save. Run simulations with --ml-log first.")
sys.exit(1)
if args.no_split:
@@ -300,7 +300,7 @@ def main():
save_dataset(val, f"{base}-val.npz")
save_dataset(test, f"{base}-test.npz")
log.info("\nDataset build complete.")
logger.info("\nDataset build complete.")
if __name__ == "__main__":
+47
View File
@@ -0,0 +1,47 @@
"""Pydantic models for Commander AI Lab API."""
from models.requests import (
StartRequest,
ImportUrlRequest,
ImportTextRequest,
MetaFetchRequest,
CreateDeckRequest,
UpdateDeckRequest,
AddDeckCardRequest,
PatchDeckCardRequest,
BulkAddRequest,
BulkAddRecommendedRequest,
DeckGenerationSourceConfig,
DeckGenerationRequest,
DeckGenV3Request,
DeckGenV3SubstituteRequest,
)
from models.responses import (
StartResponse,
StatusResponse,
DeckInfo,
GeneratedDeckCard,
)
__all__ = [
# Requests
"StartRequest",
"ImportUrlRequest",
"ImportTextRequest",
"MetaFetchRequest",
"CreateDeckRequest",
"UpdateDeckRequest",
"AddDeckCardRequest",
"PatchDeckCardRequest",
"BulkAddRequest",
"BulkAddRecommendedRequest",
"DeckGenerationSourceConfig",
"DeckGenerationRequest",
"DeckGenV3Request",
"DeckGenV3SubstituteRequest",
# Responses
"StartResponse",
"StatusResponse",
"DeckInfo",
"GeneratedDeckCard",
]
+175
View File
@@ -0,0 +1,175 @@
"""Pydantic request models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartRequest(BaseModel):
decks: list[str]
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 300
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
policyGreedy: bool = False
aiSimplified: bool = False
aiThinkTimeMs: int = -1
maxQueueDepth: int = -1
class ImportUrlRequest(BaseModel):
url: str
class ImportTextRequest(BaseModel):
text: str
commander: Optional[str] = None
class MetaFetchRequest(BaseModel):
commander: str
class CreateDeckRequest(BaseModel):
name: str
commander_scryfall_id: Optional[str] = ""
commander_name: Optional[str] = ""
color_identity: Optional[list] = []
strategy_tag: Optional[str] = ""
class UpdateDeckRequest(BaseModel):
name: Optional[str] = None
commander_scryfall_id: Optional[str] = None
commander_name: Optional[str] = None
color_identity: Optional[list] = None
strategy_tag: Optional[str] = None
class AddDeckCardRequest(BaseModel):
scryfall_id: str
card_name: Optional[str] = ""
quantity: Optional[int] = 1
is_commander: Optional[int] = 0
role_tag: Optional[str] = ""
class PatchDeckCardRequest(BaseModel):
quantity: Optional[int] = None
role_tag: Optional[str] = None
class BulkAddRequest(BaseModel):
cards: list[dict]
respect_ratios: Optional[bool] = False
class BulkAddRecommendedRequest(BaseModel):
source: str = "collection"
only_owned: Optional[bool] = True
respect_ratios: Optional[bool] = False
types: Optional[list[str]] = None
roles: Optional[list[str]] = None
class DeckGenerationSourceConfig(BaseModel):
use_archidekt: bool = True
use_edhrec: bool = True
use_moxfield: bool = False
use_mtggoldfish: bool = False
archidekt_url: Optional[str] = ""
moxfield_url: Optional[str] = ""
mtggoldfish_url: Optional[str] = ""
class DeckGenerationRequest(BaseModel):
commander_name: Optional[str] = ""
commander_scryfall_id: Optional[str] = ""
color_identity: Optional[list[str]] = None
sources: Optional[DeckGenerationSourceConfig] = None
target_land_count: int = 37
target_instant_count: int = 10
target_sorcery_count: int = 8
target_artifact_count: int = 10
target_enchantment_count: int = 8
target_creature_count: int = 25
target_planeswalker_count: int = 2
only_cards_in_collection: bool = False
allow_proxies: bool = True
deck_name: Optional[str] = ""
class DeckGenV3Request(BaseModel):
commander_name: str = ""
strategy: str = ""
target_bracket: int = 3
budget_usd: Optional[float] = None
budget_mode: str = "total"
omit_cards: list[str] = []
use_collection: bool = True
run_substitution: bool = True
model: Optional[str] = None
deck_name: Optional[str] = ""
# ══════════════════════════════════════════════════════════════
# Deckgen requests (moved from routes/deckgen.py)
# ══════════════════════════════════════════════════════════════
class DeckResearchRequest(BaseModel):
deck_id: int # Deck to research
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
budget_usd: Optional[float] = None
omit_cards: Optional[list[str]] = []
use_collection: bool = True
class DeckGenerateAIRequest(BaseModel):
commander: str
budget_usd: Optional[float] = None
budget_mode: str = "total" # "total" or "per_card"
omit_cards: Optional[list[str]] = []
use_collection: bool = True
# ══════════════════════════════════════════════════════════════
# Coach requests (moved from routes/coach.py)
# ══════════════════════════════════════════════════════════════
class CoachRequestBody(BaseModel):
goals: Optional[dict] = None
class CoachChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class CoachChatRequest(BaseModel):
deck_id: str
messages: list[dict] # conversation history [{role, content}]
goals: Optional[dict] = None
stream: Optional[bool] = False
class CoachApplyRequest(BaseModel):
session_id: str
deck_id: int # numeric deck ID in the DB
accepted_cuts: list[str] = [] # card names to remove
accepted_adds: list[str] = [] # card names to add
class CoachGoalsRequest(BaseModel):
target_power_level: Optional[int] = None # 1-10
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
class DeckGenV3SubstituteRequest(BaseModel):
card_name: str
substitute_name: str
+45
View File
@@ -0,0 +1,45 @@
"""Pydantic response models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartResponse(BaseModel):
batchId: str
status: str = "started"
message: str = ""
class StatusResponse(BaseModel):
batchId: str = ""
running: bool = False
completed: int = 0
total: int = 0
threads: int = 0
elapsedMs: int = 0
error: Optional[str] = None
simsPerSec: float = 0.0
run_id: str = ""
games_completed: int = 0
total_games: int = 0
current_decks: list = []
class DeckInfo(BaseModel):
name: str
filename: str
class GeneratedDeckCard(BaseModel):
scryfall_id: str = ""
name: str = ""
type_line: str = ""
mana_cost: str = ""
cmc: float = 0
card_type: str = ""
roles: list[str] = []
source: str = "collection"
quantity: int = 1
image_url: str = ""
owned_qty: int = 0
is_proxy: bool = False
+101
View File
@@ -0,0 +1,101 @@
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
CFG = Config()
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
active_batches: dict[str, BatchState] = {}
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
global COMMANDER_META
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META = json.load(f)
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META = BUILTIN_COMMANDERS
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
+7
View File
@@ -67,6 +67,13 @@
<version>3.45.1.0</version>
</dependency>
<!-- WebSocket server for Unity/Godot client -->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.6</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
+6
View File
@@ -7,3 +7,9 @@ uvicorn[standard]>=0.23.0
pydantic>=2.0.0
httpx>=0.24.0
websockets>=11.0
# Deck Builder dependencies
requests>=2.31.0
beautifulsoup4>=4.12.0
ollama>=0.2.0
scrython>=2.0.0
+54 -81
View File
@@ -25,12 +25,13 @@ from __future__ import annotations
import json
import os
import time
import traceback
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
from routes.shared import (
CFG,
@@ -58,14 +59,14 @@ def init_coach_service():
"""Initialize the coach service with LLM client and embeddings."""
global _coach_service, _coach_embeddings, _coach_llm, _deck_gen_v3, _deck_gen_v3_error
try:
from coach.llm_client import LMStudioClient
from coach.llm_client import LLMClient
from coach.embeddings import MTGEmbeddingIndex
from coach.coach_service import CoachService
from coach.config import ensure_dirs
ensure_dirs()
_coach_llm = LMStudioClient()
_coach_llm = LLMClient()
_coach_embeddings = MTGEmbeddingIndex()
# Try to load embeddings (non-blocking — download happens on first use)
@@ -86,42 +87,28 @@ def init_coach_service():
if llm_status.get("connected"):
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
else:
log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
log_coach.warning(f" Coach LLM: Not connected (start Ollama on 192.168.0.240:11434)")
log_coach.info(" Coach: Service initialized")
# Initialize V3 Deck Generator (Perplexity)
# Initialize V3 Deck Generator (Ollama-based)
_deck_gen_v3_error = None
if CFG.pplx_api_key:
try:
from coach.clients.perplexity_client import PerplexityClient
from coach.services.deck_generator import DeckGeneratorV3
from coach.config import DECK_GEN_MODEL
pplx_client = PerplexityClient(
api_key=CFG.pplx_api_key,
model=DECK_GEN_MODEL,
)
_deck_gen_v3 = DeckGeneratorV3(
pplx_client=pplx_client,
db_conn_factory=_get_db_conn,
embedding_index=_coach_embeddings,
)
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
except ImportError as e:
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
_deck_gen_v3 = None
except Exception as e:
_deck_gen_v3_error = str(e)
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
_deck_gen_v3 = None
else:
_deck_gen_v3_error = "PPLX_API_KEY not set"
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
try:
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
_deck_gen_v3 = DeckBuilderAdapter(
db_conn_factory=_get_db_conn,
model="gpt-oss:20b",
)
log_deckgen.info(" Deck Gen V3: Initialized (Ollama DeckBuilderAdapter)")
except ImportError as e:
_deck_gen_v3_error = f"Missing dependency: {e}"
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
_deck_gen_v3 = None
except Exception as e:
_deck_gen_v3_error = str(e)
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
_deck_gen_v3 = None
except Exception as e:
log_coach.error(f" Coach: Failed to initialize: {e}")
log_coach.error(f" Coach: Failed to initialize: {e}\n{traceback.format_exc()}")
_coach_service = None
@@ -186,33 +173,6 @@ async def coach_get_report(deck_id: str):
return report.model_dump()
class CoachRequestBody(BaseModel):
goals: Optional[dict] = None
class CoachChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class CoachChatRequest(BaseModel):
deck_id: str
messages: list[dict] # conversation history [{role, content}]
goals: Optional[dict] = None
stream: Optional[bool] = False
class CoachApplyRequest(BaseModel):
session_id: str
deck_id: int # numeric deck ID in the DB
accepted_cuts: list[str] = [] # card names to remove
accepted_adds: list[str] = [] # card names to add
class CoachGoalsRequest(BaseModel):
target_power_level: Optional[int] = None # 1-10
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
@router.post("/api/coach/decks/{deck_id}")
async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
"""Trigger a coaching session for a deck."""
@@ -261,21 +221,35 @@ def _build_deck_report_from_db(deck_slug: str):
from coach.models import DeckReport, CardPerformance, DeckStructure
conn = _get_db_conn()
# Find the deck by slug match against the deck name
rows = conn.execute(
"SELECT id, name, commander_name, color_identity FROM decks ORDER BY id"
).fetchall()
# --- Input validation ---
import re
if not deck_slug or len(deck_slug) > 200:
return None
deck_slug = re.sub(r'[^a-zA-Z0-9 _-]', '', deck_slug)
if not deck_slug:
return None
matched_deck = None
for r in rows:
name = r["name"] or ""
slug = name.lower().replace(" ", "-")
# Also try a more thorough slugify
import re
clean_slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
if slug == deck_slug.lower() or clean_slug == deck_slug.lower() or name.lower() == deck_slug.lower():
matched_deck = r
break
# Find the deck by parameterized query instead of full-table scan
slug_lower = deck_slug.lower()
slug_hyphenated = slug_lower.replace(' ', '-')
clean_slug = re.sub(r'[^a-z0-9]+', '-', slug_lower).strip('-')
if slug_lower.isdigit():
# Numeric slug -- match by ID directly
matched_deck = conn.execute(
"SELECT id, name, commander_name, color_identity FROM decks WHERE id = ?",
(int(slug_lower),)
).fetchone()
else:
# Try exact name match, hyphenated match, or clean-slug match
matched_deck = conn.execute(
"""SELECT id, name, commander_name, color_identity FROM decks
WHERE LOWER(name) = ?
OR LOWER(REPLACE(name, ' ', '-')) = ?
OR LOWER(REPLACE(name, ' ', '-')) = ?
LIMIT 1""",
(slug_lower, slug_hyphenated, clean_slug)
).fetchone()
if matched_deck is None:
return None
@@ -451,7 +425,7 @@ async def coach_chat(body: CoachChatRequest):
try:
import json
from urllib.request import urlopen, Request as UrlRequest
from coach.config import LM_STUDIO_URL, LM_STUDIO_TIMEOUT
from coach.config import LLM_URL, LLM_TIMEOUT
model_name = _coach_llm._resolve_model()
llm_body = {
@@ -477,7 +451,7 @@ async def coach_chat(body: CoachChatRequest):
import http.client
import urllib.parse
parsed = urllib.parse.urlparse(f"{_coach_llm.base_url}/chat/completions")
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LM_STUDIO_TIMEOUT)
conn = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=LLM_TIMEOUT)
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"})
resp = conn.getresponse()
@@ -526,7 +500,7 @@ async def coach_chat(body: CoachChatRequest):
import asyncio
loop = asyncio.get_event_loop()
def _call():
with urlopen(req, timeout=LM_STUDIO_TIMEOUT) as resp:
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
raw = await loop.run_in_executor(None, _call)
@@ -598,6 +572,7 @@ async def coach_apply_suggestions(body: CoachApplyRequest):
# Try Scryfall API lookup
try:
import urllib.parse
from urllib.request import urlopen, Request as UrlRequest
encoded = urllib.parse.quote(card_name)
scry_req = UrlRequest(
f"https://api.scryfall.com/cards/named?fuzzy={encoded}",
@@ -694,5 +669,3 @@ async def coach_generate_reports():
}
except Exception as e:
raise HTTPException(500, f"Report generation failed: {e}")
+1 -490
View File
@@ -18,13 +18,8 @@ from __future__ import annotations
import csv
import io
import json
import os
import re
import sqlite3
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException
@@ -320,472 +315,6 @@ async def update_collection_card(cardId: int, body: dict):
return _add_image_url(_row_to_dict(updated_row))
# ══════════════════════════════════════════════════════════════
# Collection Import Helpers
# ══════════════════════════════════════════════════════════════
# ── Scryfall Response Cache ─────────────────────────────────
# SQLite-backed cache for Scryfall API responses.
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
# Scryfall data changes infrequently (set releases ~every 3 months).
# Eliminates redundant HTTP calls across imports and re-enrichments.
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
class _ScryfallCache:
"""
Thread-safe SQLite cache for raw Scryfall JSON responses.
Schema:
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
json_blob TEXT — raw Scryfall API JSON response
fetched_at TEXT — ISO-8601 UTC timestamp
"""
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
self._lock = threading.Lock()
self._conn: Optional[sqlite3.Connection] = None
self._hits = 0
self._misses = 0
self._init_db()
# ── internal ────────────────────────────────────────────
def _init_db(self):
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("""
CREATE TABLE IF NOT EXISTS scryfall_cache (
cache_key TEXT PRIMARY KEY,
json_blob TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
self._conn.commit()
@staticmethod
def _make_key(name: str, set_code: str, collector_number: str) -> str:
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
# ── public API ──────────────────────────────────────────
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
"""Return cached Scryfall JSON dict, or None if missing / expired."""
key = self._make_key(name, set_code, collector_number)
with self._lock:
row = self._conn.execute(
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
(key,),
).fetchone()
if not row:
self._misses += 1
return None
# TTL check
try:
fetched = datetime.fromisoformat(row[1])
age = (datetime.utcnow() - fetched).total_seconds()
if age > self._ttl:
self._misses += 1
return None
except (ValueError, TypeError):
self._misses += 1
return None
self._hits += 1
try:
return json.loads(row[0])
except json.JSONDecodeError:
self._misses += 1
return None
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
"""Store a Scryfall response in the cache."""
key = self._make_key(name, set_code, collector_number)
blob = json.dumps(card_data, separators=(",", ":"))
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
"VALUES (?, ?, datetime('now'))",
(key, blob),
)
self._conn.commit()
def stats(self) -> dict:
"""Return cache statistics."""
with self._lock:
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
oldest_row = self._conn.execute(
"SELECT MIN(fetched_at) FROM scryfall_cache"
).fetchone()
newest_row = self._conn.execute(
"SELECT MAX(fetched_at) FROM scryfall_cache"
).fetchone()
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
return {
"total_entries": total,
"hits": self._hits,
"misses": self._misses,
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
"ttl_seconds": self._ttl,
"ttl_days": round(self._ttl / 86400, 1),
"oldest_entry": oldest_row[0] if oldest_row else None,
"newest_entry": newest_row[0] if newest_row else None,
"db_size_kb": round(db_size_bytes / 1024, 1),
"db_path": str(self._db_path),
}
def clear(self) -> int:
"""Delete all cached entries. Returns count of deleted rows."""
with self._lock:
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
self._conn.execute("DELETE FROM scryfall_cache")
self._conn.commit()
self._conn.execute("VACUUM") # must run outside transaction
self._hits = 0
self._misses = 0
return count
def evict_expired(self) -> int:
"""Remove entries older than TTL. Returns count of evicted rows."""
cutoff = datetime.utcnow().isoformat()
with self._lock:
self._conn.execute(
"DELETE FROM scryfall_cache WHERE "
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
(cutoff, self._ttl),
)
deleted = self._conn.total_changes
self._conn.commit()
return deleted
_scryfall_cache = _ScryfallCache()
# Scryfall rate-limit semaphore (max 10 req/sec)
_scryfall_lock = threading.Lock()
_scryfall_last_call = 0.0
def _scryfall_rate_limit():
"""Enforce at most 10 Scryfall requests per second via simple sleep."""
global _scryfall_last_call
with _scryfall_lock:
now = time.monotonic()
elapsed = now - _scryfall_last_call
if elapsed < 0.1:
time.sleep(0.1 - elapsed)
_scryfall_last_call = time.monotonic()
def _parse_finish(raw: str) -> str:
"""Normalize finish string to NORMAL|FOIL|ETCHED."""
if not raw:
return "NORMAL"
r = raw.strip().lower()
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
return "ETCHED"
if r in ("yes", "foil", "true", "1"):
return "FOIL"
return "NORMAL"
def _parse_text_line(line: str) -> dict:
"""
Parse a text import line into {name, quantity, set_code, collector_number}.
Supports formats:
- "3 Sol Ring (CMR) 123"
- "1x Sol Ring (CMR)"
- "Sol Ring [CMR:123]"
- "1 Sol Ring"
"""
line = line.strip()
if not line or line.startswith("#") or line.startswith("//"):
return {}
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
# Strip quantity prefix
qty_match = re.match(r"^(\d+)x?\s+", line)
if qty_match:
result["quantity"] = int(qty_match.group(1))
line = line[qty_match.end():]
# Format: "Name [SET:CollNum]"
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
if bracket_match:
result["set_code"] = bracket_match.group(1).lower()
result["collector_number"] = bracket_match.group(2)
line = line[:bracket_match.start()].strip()
result["name"] = line
return result
# Format: "Name (SET) CollNum"
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
if paren_coll_match:
result["set_code"] = paren_coll_match.group(1).lower()
result["collector_number"] = paren_coll_match.group(2)
line = line[:paren_coll_match.start()].strip()
result["name"] = line
return result
# Format: "Name (SET)"
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
if paren_match:
result["set_code"] = paren_match.group(1).lower()
line = line[:paren_match.start()].strip()
result["name"] = line
return result
result["name"] = line.strip()
return result
def _auto_infer_mapping(headers: list) -> dict:
"""
Auto-infer column → field mapping from header names.
Returns dict of {header: field_key}.
Field keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
"""
mapping = {}
header_lower = {h: h.lower().strip() for h in headers}
field_patterns = {
"name": ["name", "card name", "card_name", "cardname", "title"],
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
"collector_number": [
"collector number", "collector_number", "collectornumber",
"col #", "col#", "number", "card number",
],
"finish": ["finish", "foil", "printing", "treatment"],
"condition": ["condition", "cond", "grade"],
"language": ["language", "lang", "locale"],
"notes": ["notes", "note", "comments", "comment"],
"tags": ["tags", "tag", "labels", "label"],
}
for header, lower_h in header_lower.items():
for field, patterns in field_patterns.items():
if lower_h in patterns:
mapping[header] = field
break
return mapping
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
"""
Parse CSV/text content into a list of RawImportRow dicts.
Returns list of dicts with keys: name, quantity, set_code, collector_number, finish, condition, language, notes, tags
"""
rows = []
source_upper = (source or "").upper()
if source_upper == "TEXT":
for line in content.splitlines():
parsed = _parse_text_line(line)
if parsed.get("name"):
rows.append(parsed)
return rows
# CSV-based sources
reader = csv.DictReader(io.StringIO(content))
headers = reader.fieldnames or []
# Build effective mapping
if mapping:
col_map = {h: mapping.get(h, "") for h in headers}
else:
col_map = _auto_infer_mapping(headers)
# Normalize col_map field values to lowercase so both "NAME" (UI) and "name" (auto-infer) work
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
for csv_row in reader:
row = {
"name": "",
"quantity": 1,
"set_code": "",
"collector_number": "",
"finish": "NORMAL",
"condition": "",
"language": "",
"notes": "",
"tags": "",
}
for header, field in col_map.items():
val = csv_row.get(header, "").strip()
if not val or field == "ignore" or not field:
continue
if field == "name":
# Strip finish suffix like "(Foil Etched)"
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
row["name"] = val
elif field == "quantity":
try:
row["quantity"] = int(float(val))
except ValueError:
row["quantity"] = 1
elif field in ("set_code", "set_code_secondary"):
row["set_code"] = val.lower()
elif field in ("collector_number", "collector_number_secondary"):
row["collector_number"] = val
elif field == "finish":
row["finish"] = _parse_finish(val)
elif field == "condition":
row["condition"] = val
elif field == "language":
row["language"] = val
elif field == "notes":
row["notes"] = val
elif field == "tags":
row["tags"] = val
if row["name"]:
rows.append(row)
return rows
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch raw card data from Scryfall API (with caching).
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
Cache is checked first; live HTTP call only on miss.
"""
from urllib.parse import quote
# ── Cache check ──────────────────────────────────────
cached = _scryfall_cache.get(name, set_code, collector_number)
if cached is not None:
return cached
# ── Live fetch ───────────────────────────────────────
card_data = None
last_error = ""
# Try set + collector number first
if set_code and collector_number:
_scryfall_rate_limit()
try:
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e)
card_data = None
# Fall back to exact name lookup
if not card_data:
_scryfall_rate_limit()
try:
encoded_name = quote(name)
url = f"https://api.scryfall.com/cards/named?exact={encoded_name}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e)
return {"_error": f"Scryfall lookup failed for '{name}': {last_error}"}
if not card_data or card_data.get("object") == "error":
err_detail = card_data.get("details", "unknown error") if card_data else last_error
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
# ── Cache the successful response ────────────────────
_scryfall_cache.put(name, set_code, collector_number, card_data)
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
resolved_name = card_data.get("name", "")
if resolved_name and resolved_name.lower() != name.strip().lower():
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
return card_data
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch card data from Scryfall (cached) and return enriched fields dict.
Tries set+collectorNumber first, then exact name lookup.
Returns empty dict on failure.
"""
card_data = _fetch_scryfall_api(name, set_code, collector_number)
if not card_data or "_error" in card_data:
return card_data or {}
# Extract fields
type_line = card_data.get("type_line", "")
color_identity = card_data.get("color_identity", [])
keywords = card_data.get("keywords", [])
# Parse subtypes (after em-dash or hyphen)
subtypes = []
for sep in [" \u2014 ", " - "]:
if sep in type_line:
parts = type_line.split(sep, 1)
subtypes = [s.strip() for s in parts[1].split() if s.strip()]
break
is_legendary = 1 if "Legendary" in type_line else 0
is_basic = 1 if "Basic" in type_line else 0
# Get price
prices = card_data.get("prices", {})
tcg_price = 0.0
try:
price_val = prices.get("usd") or prices.get("usd_foil") or "0"
tcg_price = float(price_val) if price_val else 0.0
except (ValueError, TypeError):
tcg_price = 0.0
# Handle double-faced cards: oracle_text may be on card_faces
oracle_text = card_data.get("oracle_text", "")
if not oracle_text and card_data.get("card_faces"):
face_texts = [f.get("oracle_text", "") for f in card_data["card_faces"]]
oracle_text = "\n//\n".join(t for t in face_texts if t)
# Power/toughness (may be on card_faces for DFCs)
power = card_data.get("power", "")
toughness = card_data.get("toughness", "")
if not power and card_data.get("card_faces"):
power = card_data["card_faces"][0].get("power", "")
toughness = card_data["card_faces"][0].get("toughness", "")
# Mana cost (may be on card_faces for DFCs)
mana_cost = card_data.get("mana_cost", "")
if not mana_cost and card_data.get("card_faces"):
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
# Auto-detect functional categories
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
return {
"name": card_data.get("name", name),
"type_line": type_line,
"subtypes": json.dumps(subtypes),
"is_legendary": is_legendary,
"is_basic": is_basic,
"color_identity": json.dumps(color_identity),
"cmc": card_data.get("cmc", 0.0),
"mana_cost": mana_cost,
"oracle_text": oracle_text,
"keywords": json.dumps(keywords),
"power": power or "",
"toughness": toughness or "",
"rarity": card_data.get("rarity", ""),
"set_name": card_data.get("set_name", ""),
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
"tcg_price": tcg_price,
"salt_score": 0.0,
"is_game_changer": 0,
"category": json.dumps(auto_roles),
"scryfall_id": card_data.get("id", ""),
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
}
# ══════════════════════════════════════════════════════════════
# Collection Import Endpoint
# ══════════════════════════════════════════════════════════════
@@ -956,21 +485,3 @@ async def scryfall_cache_evict_expired():
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
# ══════════════════════════════════════════════════════════════
# Deck Builder Helpers
# ══════════════════════════════════════════════════════════════
# Type classification priority order
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
# Target ranges: {type: [min, max]}
_TYPE_TARGETS = {
"Land": [36, 38],
"Instant": [9, 11],
"Sorcery": [7, 9],
"Artifact": [9, 11],
"Creature": [20, 30],
"Enchantment": [5, 10],
"Planeswalker": [0, 5],
}
+2 -2
View File
@@ -30,6 +30,8 @@ import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlopen, Request
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
from fastapi.responses import JSONResponse
@@ -1085,7 +1087,6 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
Fuzzy-search Scryfall for a card name (used by the scanner pipeline).
Returns the raw Scryfall JSON dict, or None on failure.
"""
from urllib.parse import quote
_scryfall_rate_limit()
try:
encoded = quote(name)
@@ -1098,4 +1099,3 @@ def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
return data
except Exception:
return None
+124 -164
View File
@@ -29,10 +29,11 @@ from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlopen, Request
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
from routes.shared import (
@@ -43,6 +44,7 @@ from routes.shared import (
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
GeneratedDeckCard, DeckGenerationSourceConfig,
)
from services.deck_service import _write_dck_file, _build_dck_lines
router = APIRouter(tags=["deckgen"])
@@ -147,37 +149,48 @@ def _scryfall_to_commander(data: dict) -> dict:
def _get_collection_for_colors(color_identity: list) -> list:
"""
Fetch all collection cards compatible with the given color identity.
Returns list of dicts with card details.
"""Fetch collection cards whose color identity is a subset of *color_identity*.
Uses SQLite's json_each() to push the colour-subset check into SQL so we
only transfer the rows we actually need. The NOT EXISTS sub-query ensures
every colour symbol in the card's identity appears in the commander's
identity list. Cards with an empty colour identity (colourless / lands)
always pass because the NOT EXISTS has no rows to violate.
Only the columns consumed downstream are projected (no SELECT *).
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT * FROM collection_entries WHERE quantity > 0"
).fetchall()
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
rows = conn.execute("""
SELECT scryfall_id, name, type_line, mana_cost, cmc,
oracle_text, keywords, quantity, edhrec_rank, color_identity
FROM collection_entries
WHERE quantity > 0
AND scryfall_id != ''
AND NOT EXISTS (
SELECT 1 FROM json_each(
CASE WHEN collection_entries.color_identity IS NULL
OR collection_entries.color_identity = ''
THEN '[]'
ELSE collection_entries.color_identity
END
) AS jc
WHERE jc.value NOT IN (
SELECT value FROM json_each(?)
)
)
""", (allowed_json,)).fetchall()
valid = []
for row in rows:
card = _row_to_dict(row)
card_ci = card.get("color_identity", [])
if isinstance(card_ci, str):
try:
card_ci = json.loads(card_ci)
except Exception:
card_ci = []
# Color identity check: all card colors must be within commander's identity
if color_identity and card_ci:
if not all(c in color_identity for c in card_ci):
continue
# Skip if no scryfall_id
if not card.get("scryfall_id"):
continue
card["color_identity"] = card_ci
card = dict(row)
ci = card.get("color_identity", "[]")
try:
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
except Exception:
card["color_identity"] = []
valid.append(card)
return valid
@@ -540,29 +553,8 @@ async def deck_generator_commit(req: DeckGenerationRequest):
)
conn.commit()
# Also export a .dck file to the Forge decks directory so it appears in the Sim Lab
try:
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
if not safe_name:
safe_name = f"AutoDeck_{deck_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
dck_lines = []
dck_lines.append("[metadata]")
dck_lines.append(f"Name={deck_name}")
dck_lines.append("")
dck_lines.append("[Commander]")
dck_lines.append(f"1 {commander['name']}")
dck_lines.append("")
dck_lines.append("[Main]")
for card in cards:
card_name = card.get("name", "")
if card_name:
dck_lines.append(f"{card.get('quantity', 1)} {card_name}")
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(dck_lines))
log_deckgen.info(f" Exported .dck file: {dck_path}")
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id)
except Exception as e:
log_deckgen.warning(f" Warning: Failed to export .dck file: {e}")
@@ -655,56 +647,44 @@ async def deck_generator_commander_search(q: str = ""):
# ══════════════════════════════════════════════════════════════
class DeckResearchRequest(BaseModel):
deck_id: int # Deck to research
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
budget_usd: Optional[float] = None
omit_cards: Optional[list[str]] = []
use_collection: bool = True
class DeckGenerateAIRequest(BaseModel):
commander: str
budget_usd: Optional[float] = None
budget_mode: str = "total" # "total" or "per_card"
omit_cards: Optional[list[str]] = []
use_collection: bool = True
def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
"""Build a compact collection summary for the AI, optionally filtered by color identity."""
"""Build a compact collection summary for the AI, optionally filtered by color identity.
Uses the same json_each NOT EXISTS pattern as _get_collection_for_colors()
so that colour-identity filtering happens in SQL rather than Python.
"""
conn = _get_db_conn()
# Base query: all collection entries with oracle text
where = "WHERE quantity > 0"
params = []
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
# Filter by color identity if provided
if color_identity:
# Cards must only contain colors in the commander's identity (+ colorless)
ci_set = set(c.upper() for c in color_identity)
# We'll do a post-filter since color_identity is JSON in the DB
pass # filter in Python after fetch
# SQL handles the colour-identity subset check via json_each
ci_filter = """
AND NOT EXISTS (
SELECT 1 FROM json_each(
CASE WHEN collection_entries.color_identity IS NULL
OR collection_entries.color_identity = ''
THEN '[]'
ELSE collection_entries.color_identity
END
) AS jc
WHERE jc.value NOT IN (
SELECT value FROM json_each(?)
)
)
""" if color_identity else ""
params = (allowed_json,) if color_identity else ()
rows = conn.execute(f"""
SELECT name, type_line, cmc, oracle_text, keywords, tcg_price, quantity,
color_identity, category, is_game_changer, salt_score
FROM collection_entries {where}
FROM collection_entries
WHERE quantity > 0
{ci_filter}
ORDER BY edhrec_rank ASC, tcg_price DESC
""", params).fetchall()
# Color identity filter
def card_fits_identity(card_ci_str, ci_set):
if not ci_set:
return True
try:
card_ci = json.loads(card_ci_str) if card_ci_str else []
if not isinstance(card_ci, list):
return True
return all(c.upper() in ci_set for c in card_ci if c.upper() not in ('', 'C'))
except Exception:
return True
# Role detection from type_line, oracle_text, and category
def detect_role(row):
tl = (row['type_line'] or '').lower()
@@ -746,12 +726,9 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
'ramp': [], 'card_draw': [], 'removal': [], 'board_wipes': [],
'lands': [], 'win_conditions': [], 'creatures': [], 'utility': []
}
filtered_count = 0
filtered_count = len(rows) # SQL already filtered
for r in rows:
if color_identity and not card_fits_identity(r['color_identity'], ci_set):
continue
filtered_count += 1
role = detect_role(r)
groups[role].append({
'name': r['name'],
@@ -789,40 +766,57 @@ def _build_collection_summary(color_identity: list[str] | None = None) -> dict:
}
def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
"""Call Perplexity API chat/completions endpoint. Returns the assistant message content."""
if not CFG.pplx_api_key:
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: float = 0.2) -> str:
"""Call Perplexity API chat/completions endpoint (non-blocking).
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
Returns the assistant message content.
"""
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL
if DECK_GEN_PROVIDER == 'local':
base_url = DECK_GEN_BASE_URL
model = DECK_GEN_MODEL
headers = {'Content-Type': 'application/json'}
else:
if not CFG.pplx_api_key:
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
base_url = 'https://api.perplexity.ai'
model = 'sonar'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {CFG.pplx_api_key}',
}
payload = {
'model': 'sonar',
'model': model,
'messages': messages,
'max_tokens': max_tokens,
'temperature': temperature,
'return_related_questions': False,
}
req_data = json.dumps(payload).encode('utf-8')
req = Request('https://api.perplexity.ai/chat/completions', data=req_data, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Authorization', f'Bearer {CFG.pplx_api_key}')
try:
with urlopen(req, timeout=120) as resp:
data = json.loads(resp.read())
choices = data.get('choices', [])
if not choices:
raise ValueError('Empty response from Perplexity API')
content = choices[0].get('message', {}).get('content', '')
usage = data.get('usage', {})
log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, '
f'completion={usage.get("completion_tokens","?")}, '
f'model={data.get("model","?")}')
return content
except URLError as e:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f'{base_url}/chat/completions',
json=payload,
headers=headers,
)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as e:
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}: {e.response.text[:200]}')
except httpx.RequestError as e:
raise HTTPException(502, f'Perplexity API call failed: {e}')
except json.JSONDecodeError as e:
raise HTTPException(502, f'Perplexity API returned invalid JSON: {e}')
choices = data.get('choices', [])
if not choices:
raise HTTPException(502, 'Empty response from Perplexity API')
content = choices[0].get('message', {}).get('content', '')
usage = data.get('usage', {})
log_pplx.debug(
f'tokens: prompt={usage.get("prompt_tokens", "?")}, '
f'completion={usage.get("completion_tokens", "?")}, '
f'model={data.get("model", "?")}'
)
return content
def _extract_json_from_response(text: str) -> dict:
@@ -1082,7 +1076,7 @@ DECKLIST ({len(card_rows)} cards):
Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4 per Commander Rules Committee), ALL synergy packages between cards, ALL win conditions, game plan by phase (early/mid/late), mana base health, every role gap. Suggest specific cuts with severity and specific adds with priority and synergy tags. For adds, prioritize cards from the COLLECTION SUMMARY when available."""
content = _call_pplx_api([
content = await _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192)
@@ -1192,7 +1186,7 @@ COLOR IDENTITY: {', '.join(color_identity) if color_identity else 'Unknown'}
Build the deck as JSON. Prioritize collection cards when they fit the strategy."""
content = _call_pplx_api([
content = await _call_pplx_api([
{'role': 'system', 'content': system_msg},
{'role': 'user', 'content': user_msg},
], max_tokens=8192, temperature=0.3)
@@ -1224,7 +1218,6 @@ async def pplx_status():
"""Check if Perplexity API is configured."""
return {
'configured': bool(CFG.pplx_api_key),
'key_prefix': CFG.pplx_api_key[:8] + '...' if CFG.pplx_api_key else '',
}
@@ -1238,13 +1231,9 @@ async def deck_gen_v3_status():
return {
'initialized': _coach._deck_gen_v3 is not None,
'pplx_configured': bool(CFG.pplx_api_key),
'model': _coach._deck_gen_v3.pplx.model if _coach._deck_gen_v3 else None,
'embeddings_loaded': (
_coach_embeddings.loaded if _coach_embeddings else False
),
'embedding_cards': (
_coach_embeddings.card_count if _coach_embeddings else 0
),
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
'embeddings_loaded': False,
'embedding_cards': 0,
'error': _coach._deck_gen_v3_error,
}
@@ -1263,7 +1252,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
6. Return complete deck with substitution data
"""
if _coach._deck_gen_v3 is None:
raise HTTPException(503, 'V3 Deck Generator not initialized. Check PPLX_API_KEY.')
raise HTTPException(503, 'V3 Deck Generator not initialized. Ensure Ollama is running with gpt-oss:20b model.')
if not req.commander_name or len(req.commander_name.strip()) < 2:
raise HTTPException(400, 'Commander name is required (min 2 chars)')
@@ -1305,9 +1294,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
except ValueError as e:
return JSONResponse({'error': str(e)}, status_code=422)
except Exception as e:
log_deckgen.error(f'Error: {e}')
import traceback
traceback.print_exc()
log_deckgen.error(f'V3 generate error: {e}', exc_info=True)
raise HTTPException(500, f'Deck generation failed: {str(e)}')
@@ -1394,25 +1381,8 @@ async def deck_gen_v3_commit(req: DeckGenV3Request):
)
conn.commit()
# Export .dck for Forge
try:
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', deck_name).strip().replace(' ', '_')
if not safe_name:
safe_name = f"V3Deck_{deck_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
dck_lines = ["[metadata]", f"Name={deck_name}", "",
"[Commander]", f"1 {commander['name']}", "", "[Main]"]
for card in cards:
cname = card.get('name', '')
if card.get('status') == 'substituted' and card.get('selected_substitute'):
cname = card['selected_substitute']
if cname and cname != commander.get('name', ''):
dck_lines.append(f"{card.get('count', 1)} {cname}")
with open(dck_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(dck_lines))
log_deckgen.info(f" Exported .dck: {dck_path}")
_write_dck_file(deck_name, commander['name'], cards, fallback_id=deck_id, resolve_substitutes=True)
except Exception as e:
log_deckgen.error(f" .dck export failed: {e}")
@@ -1497,22 +1467,12 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
cards = result.get('cards', [])
commander = result.get('commander', {})
lines = [
'[metadata]',
f'Name={commander.get("name", "Deck")} - V3 Auto',
'',
'[Commander]',
f'1 {commander.get("name", "")}',
'',
'[Main]',
]
for card in cards:
cname = card.get('name', '')
if card.get('status') == 'substituted' and card.get('selected_substitute'):
cname = card['selected_substitute']
if cname and cname != commander.get('name', ''):
lines.append(f"{card.get('count', 1)} {cname}")
lines = _build_dck_lines(
f"{commander.get('name', 'Deck')} - V3 Auto",
commander.get('name', ''),
cards,
resolve_substitutes=True,
)
dck_content = '\n'.join(lines)
safe_name = re.sub(r'[^a-zA-Z0-9_\-\s]', '', commander.get('name', 'deck')).strip().replace(' ', '_')
return StreamingResponse(
+565 -534
View File
File diff suppressed because it is too large. Load diff
+21 -5
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import glob
import json
import math
import os
import re
import threading
@@ -38,8 +39,6 @@ from routes.shared import (
StartResponse,
StatusResponse,
AI_PROFILES,
PRECON_DIR,
PRECON_INDEX,
build_java_command,
run_batch_subprocess,
_run_deepseek_batch_thread,
@@ -54,6 +53,19 @@ from routes.shared import (
router = APIRouter(tags=["lab"])
def _sanitize_floats(obj):
"""Replace NaN/Infinity with None so JSONResponse (allow_nan=False) won't crash."""
if isinstance(obj, float):
if math.isnan(obj) or math.isinf(obj):
return None
return obj
if isinstance(obj, dict):
return {k: _sanitize_floats(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize_floats(v) for v in obj]
return obj
@router.post("/api/lab/start", response_model=StartResponse)
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
"""Start a new batch simulation run."""
@@ -164,8 +176,12 @@ async def get_result(batchId: Optional[str] = None):
raise HTTPException(500, state.error)
if not state.result_path or not os.path.exists(state.result_path):
raise HTTPException(500, "Result file not found")
with open(state.result_path, "r", encoding="utf-8") as f:
data = json.load(f)
try:
with open(state.result_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
raise HTTPException(500, f"Failed to read result file: {e}")
data = _sanitize_floats(data)
return JSONResponse(content=data)
@@ -441,5 +457,5 @@ async def start_batch_deepseek(request: FastAPIRequest, background_tasks: Backgr
# Global ML logging toggle (can be enabled via API)
_ml_logging_enabled = False
_ml_logging_enabled = True
+729
View File
@@ -0,0 +1,729 @@
"""
routes/ml.py
============
ML training data, policy inference, supervised training,
PPO training, and tournament endpoints.
GET /api/ml/status
GET /api/ml/decisions/{filename}
GET /api/ml/data/status
POST /api/ml/toggle
POST /api/ml/predict
POST /api/ml/predict/batch
GET /api/ml/model
POST /api/ml/reload
POST /api/ml/train
GET /api/ml/train/status
POST /api/ml/train/ppo
GET /api/ml/train/ppo/status
POST /api/ml/tournament
GET /api/ml/tournament/status
GET /api/ml/tournament/results
"""
from __future__ import annotations
import dataclasses
import json
import logging
import os
import threading
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
from routes.shared import CFG, _ml_logging_enabled
log_ml = logging.getLogger("commander_ai_lab.ml")
router = APIRouter(tags=["ml"])
# ── module-level singletons ──────────────────────────────────────────────────
_policy_service = None
_policy_service_init_attempted = False
# ── Thread-safe state objects ────────────────────────────────────────────────
@dataclasses.dataclass
class _TrainingState:
running: bool = False
progress: int = 0
total_epochs: int = 0
current_epoch: int = 0
phase: str = "idle"
message: str = ""
metrics: object = None
result: object = None
error: object = None
started_at: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
@dataclasses.dataclass
class _PPOState:
running: bool = False
iteration: int = 0
total_iterations: int = 0
phase: str = "idle"
message: str = ""
metrics: object = None
result: object = None
error: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
@dataclasses.dataclass
class _TournamentState:
running: bool = False
phase: str = "idle"
message: str = ""
result: object = None
error: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
_training_state = _TrainingState()
_training_lock = threading.Lock()
_ppo_state = _PPOState()
_ppo_lock = threading.Lock()
_tournament_state = _TournamentState()
_tournament_lock = threading.Lock()
# module-level toggle (local copy; the shared one is for batch runs)
_ml_logging_enabled_local = _ml_logging_enabled
def _get_policy_service():
"""Get or initialize the policy inference service (lazy loading)."""
global _policy_service, _policy_service_init_attempted
if _policy_service is None and not _policy_service_init_attempted:
_policy_service_init_attempted = True
try:
from ml.serving.policy_server import PolicyInferenceService
_policy_service = PolicyInferenceService()
if _policy_service.load():
log_ml.info(f"Policy model loaded on {_policy_service.device}")
else:
log_ml.error(f"Policy model not available: {_policy_service._load_error}")
except Exception as e:
log_ml.error(f"Policy service init failed: {e}")
return _policy_service
# ==============================================================
# ML Training Data Endpoints
# ==============================================================
@router.get("/api/ml/status")
async def ml_status():
"""Get ML decision logging status and available training data."""
import routes.shared as _shared
lab_root = Path(__file__).resolve().parent.parent
results_dir = lab_root / CFG.results_dir
ml_files = []
if results_dir.exists():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
lines = 0
try:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
ml_files.append({
"file": f.name,
"decisions": lines,
"size_kb": round(f.stat().st_size / 1024, 1),
})
total_decisions = sum(f["decisions"] for f in ml_files)
return {
"ml_logging_enabled": _shared._ml_logging_enabled,
"training_files": ml_files,
"total_decisions": total_decisions,
"total_files": len(ml_files),
}
@router.post("/api/ml/toggle")
async def ml_toggle(enable: bool = True):
"""Enable or disable ML decision logging for future batch runs."""
import routes.shared as _shared
_shared._ml_logging_enabled = enable
return {
"ml_logging_enabled": _shared._ml_logging_enabled,
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
}
@router.get("/api/ml/decisions/{filename}")
async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
"""Read decision snapshots from a training data file."""
lab_root = Path(__file__).resolve().parent.parent
filepath = lab_root / CFG.results_dir / filename
if not filepath.exists() or not filename.startswith("ml-decisions-"):
raise HTTPException(404, f"ML decisions file not found: {filename}")
decisions = []
try:
with open(filepath) as f:
for i, line in enumerate(f):
if i < offset:
continue
if len(decisions) >= limit:
break
try:
decisions.append(json.loads(line.strip()))
except json.JSONDecodeError:
continue
except Exception as e:
raise HTTPException(500, f"Failed to read decisions: {e}")
return {
"file": filename,
"offset": offset,
"limit": limit,
"count": len(decisions),
"decisions": decisions,
}
# ==============================================================
# ML Policy Inference Endpoints
# ==============================================================
@router.post("/api/ml/predict")
async def ml_predict(request: FastAPIRequest):
"""Predict a macro-action from a game state snapshot."""
svc = _get_policy_service()
if svc is None or not svc._loaded:
detail = "Policy model not loaded. "
if svc:
detail += svc._load_error or "Train a model first."
else:
detail += "PyTorch may not be installed."
raise HTTPException(status_code=503, detail=detail)
body = await request.json()
playstyle = body.pop("archetype", "midrange")
temperature = body.pop("temperature", 1.0)
greedy = body.pop("greedy", False)
result = svc.predict(
decision_snapshot=body,
playstyle=playstyle,
temperature=temperature,
greedy=greedy,
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@router.post("/api/ml/predict/batch")
async def ml_predict_batch(request: FastAPIRequest):
"""Predict actions for multiple snapshots at once."""
svc = _get_policy_service()
if svc is None or not svc._loaded:
raise HTTPException(status_code=503, detail="Policy model not loaded")
body = await request.json()
snapshots = body.get("snapshots", [])
greedy = body.get("greedy", True)
if not snapshots:
return {"results": []}
results = svc.predict_batch(snapshots, greedy=greedy)
return {"results": results, "count": len(results)}
@router.get("/api/ml/model")
async def ml_model_info():
"""Get information about the loaded policy model."""
svc = _get_policy_service()
if svc is None:
return {
"loaded": False,
"error": "Policy service not initialized",
"torch_available": False,
}
return svc.get_status()
@router.post("/api/ml/reload")
async def ml_reload_model(checkpoint: str = None):
"""Hot-reload a policy model checkpoint."""
svc = _get_policy_service()
if svc is None:
raise HTTPException(status_code=503, detail="Policy service not available")
if not svc._loaded:
ok = svc.load()
else:
ok = svc.reload(checkpoint)
return {
"success": ok,
"status": svc.get_status(),
}
# ==============================================================
# ML Training Management Endpoints
# ==============================================================
def _run_training_pipeline(
results_dir: str, epochs: int, lr: float,
batch_size: int, patience: int, rebuild_dataset: bool,
):
"""Run the full ML training pipeline in a background thread."""
global _policy_service, _policy_service_init_attempted
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _training_lock:
_training_state.running = True
_training_state.started_at = datetime.now().isoformat()
_training_state.error = None
_training_state.result = None
data_dir = os.path.join(project_root, "ml", "models")
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
os.makedirs(data_dir, exist_ok=True)
os.makedirs(ckpt_dir, exist_ok=True)
# Phase 1: Build Dataset
train_path = os.path.join(data_dir, "train.npz")
if rebuild_dataset or not os.path.exists(train_path):
with _training_lock:
_training_state.phase = "building"
_training_state.message = "Loading card embeddings & building dataset..."
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
dataset = build_dataset(results_dir=results_dir)
if not dataset:
raise RuntimeError("No training data produced. Check server log for details.")
train_ds, val_ds, test_ds = split_dataset(dataset)
save_dataset(train_ds, os.path.join(data_dir, "train.npz"))
save_dataset(val_ds, os.path.join(data_dir, "val.npz"))
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
total_samples = len(dataset["states"])
with _training_lock:
_training_state.message = f"Dataset built: {total_samples} samples"
log_ml.info(f"Dataset built: {total_samples} samples")
# Phase 2: Train
with _training_lock:
_training_state.phase = "training"
_training_state.total_epochs = epochs
_training_state.current_epoch = 0
_training_state.message = f"Training policy network ({epochs} epochs)..."
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
import numpy as np
import torch
from ml.training.policy_network import PolicyNetwork
from ml.training.trainer import SupervisedTrainer
def load_npz_split(path):
data = np.load(path)
return data["states"].astype(np.float32), data["labels"].astype(np.int64)
train_states, train_labels = load_npz_split(train_path)
val_path = os.path.join(data_dir, "val.npz")
val_states, val_labels = load_npz_split(val_path)
actual_dim = train_states.shape[1]
model = PolicyNetwork(input_dim=actual_dim)
device = "cpu"
if torch.cuda.is_available():
device = "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
trainer = SupervisedTrainer(
model=model, device=device, learning_rate=lr,
batch_size=batch_size, epochs=epochs, patience=patience,
checkpoint_dir=ckpt_dir,
)
summary = trainer.train(train_states, train_labels, val_states, val_labels)
with _training_lock:
_training_state.current_epoch = summary.get("epochs_trained", epochs)
_training_state.metrics = summary
# Phase 3: Evaluate
with _training_lock:
_training_state.phase = "evaluating"
_training_state.message = "Evaluating on test set..."
test_path = os.path.join(data_dir, "test.npz")
eval_results = None
if os.path.exists(test_path):
from ml.training.trainer import load_checkpoint, evaluate_model
test_states, test_labels = load_npz_split(test_path)
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
eval_results = evaluate_model(best_model, test_states, test_labels, device)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(eval_results, f, indent=2)
# Done
with _training_lock:
_training_state.phase = "done"
_training_state.running = False
_training_state.result = {
"training": summary,
"evaluation": eval_results,
"checkpoint": summary.get("checkpoint_path", ""),
"device": device,
}
_training_state.message = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
# Auto-reload policy server
if _policy_service and _policy_service._loaded:
_policy_service.reload(summary.get("checkpoint_path"))
log_ml.info("Policy server reloaded with new checkpoint")
elif not _policy_service_init_attempted:
_policy_service_init_attempted = False
except Exception as e:
import traceback
with _training_lock:
_training_state.phase = "error"
_training_state.running = False
_training_state.error = str(e)
_training_state.message = f"Training failed: {e}"
log_ml.error(f"ERROR: {e}")
traceback.print_exc()
@router.post("/api/ml/train")
async def ml_start_training(request: FastAPIRequest):
"""Trigger ML training pipeline from the web UI."""
if _training_state.running:
raise HTTPException(409, "Training already in progress")
body = await request.json() if await request.body() else {}
epochs = body.get("epochs", 50)
lr = body.get("lr", 0.001)
batch_size = body.get("batchSize", 256)
patience = body.get("patience", 10)
rebuild = body.get("rebuildDataset", True)
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
with _training_lock:
_training_state.running = True
_training_state.progress = 0
_training_state.total_epochs = epochs
_training_state.current_epoch = 0
_training_state.phase = "starting"
_training_state.message = "Initializing training pipeline..."
_training_state.metrics = None
_training_state.result = None
_training_state.error = None
thread = threading.Thread(
target=_run_training_pipeline,
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
daemon=True,
)
thread.start()
return {
"status": "started",
"config": {
"epochs": epochs, "lr": lr, "batchSize": batch_size,
"patience": patience, "rebuildDataset": rebuild,
},
}
@router.get("/api/ml/train")
async def ml_training_status_poll():
"""Return current training status (polled by Unity client)."""
with _training_lock:
return _training_state.snapshot()
@router.get("/api/ml/train/status")
async def ml_training_status():
"""Get current training pipeline status."""
with _training_lock:
return _training_state.snapshot()
@router.get("/api/ml/data/status")
async def ml_data_status():
"""Get status of available training data and model checkpoints."""
project_root = Path(__file__).resolve().parent.parent
results_dir = project_root / "results"
data_dir = project_root / "ml" / "models"
ckpt_dir = data_dir / "checkpoints"
decision_files = []
if results_dir.exists():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
line_count = 0
try:
with open(f, "r") as fh:
line_count = sum(1 for _ in fh)
except Exception:
pass
decision_files.append({"name": f.name, "size": f.stat().st_size, "decisions": line_count})
datasets = {}
for split in ["train", "val", "test"]:
path = data_dir / f"{split}.npz"
if path.exists():
try:
import numpy as np
data = np.load(str(path))
datasets[split] = {
"samples": int(data["states"].shape[0]),
"features": int(data["states"].shape[1]),
"size": path.stat().st_size,
}
except Exception:
datasets[split] = {"size": path.stat().st_size}
checkpoints = []
if ckpt_dir.exists():
for f in sorted(ckpt_dir.glob("*.pt")):
checkpoints.append({
"name": f.name,
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
eval_path = ckpt_dir / "eval_results.json"
eval_results = None
if eval_path.exists():
try:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
return {
"decisionFiles": decision_files,
"totalDecisions": sum(d["decisions"] for d in decision_files),
"datasets": datasets,
"checkpoints": checkpoints,
"evalResults": eval_results,
"policyLoaded": _policy_service is not None and _policy_service._loaded if _policy_service else False,
}
# ==============================================================
# PPO Training + Tournament Endpoints
# ==============================================================
def _run_ppo_pipeline(
iterations: int, episodes_per_iter: int, ppo_epochs: int,
batch_size: int, lr: float, clip_epsilon: float,
entropy_coeff: float, opponent: str, playstyle: str,
load_supervised: str,
):
"""Run PPO training in a background thread."""
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _ppo_lock:
_ppo_state.running = True
_ppo_state.error = None
_ppo_state.result = None
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
config = PPOConfig(
iterations=iterations, episodes_per_iter=episodes_per_iter,
ppo_epochs=ppo_epochs, batch_size=batch_size,
learning_rate=lr, clip_epsilon=clip_epsilon,
entropy_coeff=entropy_coeff, opponent=opponent,
playstyle=playstyle, checkpoint_dir=ckpt_dir,
load_supervised=load_supervised if load_supervised else None,
)
trainer = PPOTrainer(config)
def progress_cb(iteration, metrics):
with _ppo_lock:
_ppo_state.iteration = iteration
_ppo_state.phase = "training"
_ppo_state.message = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
_ppo_state.metrics = metrics
summary = trainer.train(progress_callback=progress_cb)
with _ppo_lock:
_ppo_state.phase = "done"
_ppo_state.running = False
_ppo_state.result = summary
_ppo_state.message = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
except Exception as e:
import traceback
with _ppo_lock:
_ppo_state.phase = "error"
_ppo_state.running = False
_ppo_state.error = str(e)
_ppo_state.message = f"PPO failed: {e}"
traceback.print_exc()
@router.post("/api/ml/train/ppo")
async def ml_start_ppo(request: FastAPIRequest):
"""Start PPO training pipeline."""
if _ppo_state.running:
raise HTTPException(409, "PPO training already in progress")
if _training_state.running:
raise HTTPException(409, "Supervised training in progress -- wait for it to finish")
body = await request.json() if await request.body() else {}
iterations = body.get("iterations", 100)
episodes = body.get("episodesPerIter", 64)
ppo_epochs = body.get("ppoEpochs", 4)
batch_size = body.get("batchSize", 256)
lr = body.get("lr", 3e-4)
clip_eps = body.get("clipEpsilon", 0.2)
entropy = body.get("entropyCoeff", 0.01)
opponent = body.get("opponent", "heuristic")
playstyle = body.get("playstyle", "midrange")
load_sup = body.get("loadSupervised", "")
with _ppo_lock:
_ppo_state.running = True
_ppo_state.iteration = 0
_ppo_state.total_iterations = iterations
_ppo_state.phase = "starting"
_ppo_state.message = "Initializing PPO..."
_ppo_state.metrics = None
_ppo_state.result = None
_ppo_state.error = None
thread = threading.Thread(
target=_run_ppo_pipeline,
args=(iterations, episodes, ppo_epochs, batch_size, lr,
clip_eps, entropy, opponent, playstyle, load_sup),
daemon=True,
)
thread.start()
return {"status": "started", "iterations": iterations}
@router.get("/api/ml/train/ppo/status")
async def ml_ppo_status():
"""Get PPO training status."""
with _ppo_lock:
return _ppo_state.snapshot()
def _run_tournament_pipeline(episodes: int, playstyle: str):
"""Run tournament in a background thread."""
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _tournament_lock:
_tournament_state.running = True
_tournament_state.error = None
_tournament_state.result = None
_tournament_state.phase = "running"
_tournament_state.message = "Running tournament..."
from ml.eval.tournament import (
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
)
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
policies = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
sup_path = os.path.join(ckpt_dir, "best_policy.pt")
if os.path.exists(sup_path):
policies["supervised"] = LearnedPolicy(sup_path)
ppo_path = os.path.join(ckpt_dir, "best_ppo.pt")
if os.path.exists(ppo_path):
policies["ppo"] = LearnedPolicy(ppo_path)
result = run_tournament(
policies=policies,
episodes_per_matchup=episodes,
playstyle=playstyle,
)
output_path = os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w") as f:
json.dump(result.to_dict(), f, indent=2)
with _tournament_lock:
_tournament_state.phase = "done"
_tournament_state.running = False
_tournament_state.result = result.to_dict()
_tournament_state.message = f"Tournament complete! {result.total_matches} matches"
except Exception as e:
import traceback
with _tournament_lock:
_tournament_state.phase = "error"
_tournament_state.running = False
_tournament_state.error = str(e)
_tournament_state.message = f"Tournament failed: {e}"
traceback.print_exc()
@router.post("/api/ml/tournament")
async def ml_start_tournament(request: FastAPIRequest):
"""Start a tournament evaluation."""
if _tournament_state.running:
raise HTTPException(409, "Tournament already in progress")
body = await request.json() if await request.body() else {}
episodes = body.get("episodes", 50)
playstyle = body.get("playstyle", "midrange")
with _tournament_lock:
_tournament_state.running = True
_tournament_state.phase = "starting"
_tournament_state.message = "Initializing tournament..."
_tournament_state.result = None
_tournament_state.error = None
thread = threading.Thread(
target=_run_tournament_pipeline,
args=(episodes, playstyle),
daemon=True,
)
thread.start()
return {"status": "started", "episodes": episodes}
@router.get("/api/ml/tournament/status")
async def ml_tournament_status():
"""Get tournament status."""
with _tournament_lock:
return _tournament_state.snapshot()
@router.get("/api/ml/tournament/results")
async def ml_tournament_results():
"""Get latest tournament results."""
project_root = Path(__file__).resolve().parent.parent
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
if results_path.exists():
with open(results_path, "r") as f:
return json.load(f)
return {"error": "No tournament results found. Run a tournament first."}
# ==============================================================
# Model listing and stats endpoints
# ==============================================================
@router.get("/api/ml/models")
async def ml_list_models():
"""List available policy model checkpoints."""
project_root = Path(__file__).resolve().parent.parent
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
checkpoints = []
if ckpt_dir.exists():
for f in sorted(ckpt_dir.glob("*.pt")):
checkpoints.append({
"name": f.name,
"path": str(f),
"size_kb": round(f.stat().st_size / 1024, 1),
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
svc = _get_policy_service()
return {
"models": checkpoints,
"count": len(checkpoints),
"active_model": svc.get_status() if svc else None,
}
@router.get("/api/ml/stats")
async def ml_stats():
"""Return aggregate ML training statistics and model performance metrics."""
project_root = Path(__file__).resolve().parent.parent
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
eval_results = None
eval_path = ckpt_dir / "eval_results.json"
if eval_path.exists():
try:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
with _training_lock:
training_snapshot = _training_state.snapshot()
with _ppo_lock:
ppo_snapshot = _ppo_state.snapshot()
svc = _get_policy_service()
return {
"training": training_snapshot,
"ppo": ppo_snapshot,
"eval_results": eval_results,
"policy_loaded": svc._loaded if svc else False,
}
+8 -7
View File
@@ -16,7 +16,8 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
import routes.shared as _shared # module reference, not value import
from routes.shared import CFG, PRECON_DIR, download_precon_database, log
from routes.shared import CFG, download_precon_database, log
from services.precon_service import _get_precon_dir
router = APIRouter(tags=["precon"])
@@ -30,13 +31,13 @@ async def list_precons():
@router.post("/api/lab/precons/install")
async def install_precon(req: dict):
"""Install a precon deck to the Forge decks directory.
Body: {"fileName": "Elven_Empire.dck"}
Body: {"fileName": "Elven_Empire.dck"} or {"name": "Elven_Empire.dck"}
"""
file_name = req.get("fileName", "")
file_name = req.get("fileName", "") or req.get("name", "")
if not file_name:
raise HTTPException(400, "fileName is required")
src = PRECON_DIR / file_name
src = _get_precon_dir() / file_name
if not src.exists():
raise HTTPException(404, f"Precon not found: {file_name}")
@@ -59,9 +60,9 @@ async def install_precon(req: dict):
@router.post("/api/lab/precons/install-batch")
async def install_precons_batch(req: dict):
"""Install multiple precon decks at once.
Body: {"fileNames": ["Elven_Empire.dck", "Necron_Dynasties.dck"]}
Body: {"fileNames": ["Elven_Empire.dck", ...]} or {"names": ["Elven_Empire.dck", ...]}
"""
file_names = req.get("fileNames", [])
file_names = req.get("fileNames", []) or req.get("names", [])
if not file_names:
raise HTTPException(400, "fileNames list is required")
@@ -71,7 +72,7 @@ async def install_precons_batch(req: dict):
results = []
for file_name in file_names:
src = PRECON_DIR / file_name
src = _get_precon_dir() / file_name
if not src.exists():
results.append({"fileName": file_name, "installed": False, "error": "not found"})
continue
+67 -1768
View File
File diff suppressed because it is too large. Load diff
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
scripts/bench_deckgen.py
========================
Benchmark for _get_collection_for_colors() and _build_collection_summary().
Seeds an in-memory SQLite DB with randomised collection_entries rows,
then times the SQL-filtered query for various commander colour identities.
Usage:
python scripts/bench_deckgen.py [--rows 1000]
"""
from __future__ import annotations
import argparse
import json
import random
import sqlite3
import time
from typing import List
# ── Helpers ──────────────────────────────────────────────────────────────────
ALL_COLORS = ["W", "U", "B", "R", "G"]
CARD_TYPE_LINES = [
"Creature - Human Soldier",
"Creature - Elf Druid",
"Instant",
"Sorcery",
"Artifact",
"Enchantment",
"Land",
"Artifact Creature - Golem",
"Legendary Creature - Dragon",
"Planeswalker - Jace",
]
def _random_color_identity() -> list:
"""Return a random colour identity (including ~15% chance of colorless)."""
if random.random() < 0.15:
return []
n = random.randint(1, 3)
return sorted(random.sample(ALL_COLORS, n))
def _seed_db(conn: sqlite3.Connection, num_rows: int):
"""Create collection_entries table and insert random rows."""
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
color_identity TEXT DEFAULT '[]',
cmc REAL DEFAULT 0,
mana_cost TEXT DEFAULT '',
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '[]',
quantity INTEGER DEFAULT 1,
edhrec_rank INTEGER DEFAULT 0,
scryfall_id TEXT DEFAULT '',
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT ''
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ce_quantity
ON collection_entries (quantity) WHERE quantity > 0
""")
rows = []
for i in range(num_rows):
ci = _random_color_identity()
qty = random.choice([0, 1, 1, 1, 2, 3]) # ~17% zero quantity
rows.append((
f"Card_{i:05d}",
random.choice(CARD_TYPE_LINES),
json.dumps(ci),
round(random.uniform(0, 8), 1),
"",
f"Sample oracle text for card {i}",
json.dumps([]),
qty,
random.randint(1, 20000),
f"scryfall-{i:05d}" if random.random() > 0.02 else "",
round(random.uniform(0, 50), 2),
round(random.uniform(0, 5), 2),
1 if random.random() < 0.05 else 0,
random.choice(["", "ramp", "draw", "removal", ""]),
))
conn.executemany("""
INSERT INTO collection_entries
(name, type_line, color_identity, cmc, mana_cost, oracle_text,
keywords, quantity, edhrec_rank, scryfall_id, tcg_price,
salt_score, is_game_changer, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", rows)
conn.commit()
print(f" Seeded {num_rows} rows ({sum(1 for r in rows if r[7] > 0)} with qty > 0)")
# ── Query under test (same as routes/deckgen.py) ────────────────────────────
def get_collection_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
"""Mirrors the new SQL-filtered implementation."""
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
rows = conn.execute("""
SELECT scryfall_id, name, type_line, mana_cost, cmc,
oracle_text, keywords, quantity, edhrec_rank, color_identity
FROM collection_entries
WHERE quantity > 0
AND scryfall_id != ''
AND NOT EXISTS (
SELECT 1 FROM json_each(
CASE WHEN collection_entries.color_identity IS NULL
OR collection_entries.color_identity = ''
THEN '[]'
ELSE collection_entries.color_identity
END
) AS jc
WHERE jc.value NOT IN (
SELECT value FROM json_each(?)
)
)
""", (allowed_json,)).fetchall()
valid = []
for row in rows:
card = dict(row)
ci = card.get("color_identity", "[]")
try:
card["color_identity"] = json.loads(ci) if isinstance(ci, str) else ci
except Exception:
card["color_identity"] = []
valid.append(card)
return valid
def get_collection_for_colors_old(conn: sqlite3.Connection, color_identity: list) -> list:
"""Old implementation: SELECT * + Python-side filter."""
rows = conn.execute(
"SELECT * FROM collection_entries WHERE quantity > 0"
).fetchall()
valid = []
for row in rows:
card = dict(row)
card_ci = card.get("color_identity", "[]")
if isinstance(card_ci, str):
try:
card_ci = json.loads(card_ci)
except Exception:
card_ci = []
if color_identity and card_ci:
if not all(c in color_identity for c in card_ci):
continue
if not card.get("scryfall_id"):
continue
card["color_identity"] = card_ci
valid.append(card)
return valid
# ── Main ─────────────────────────────────────────────────────────────────────
COMMANDERS = [
("Mono-W", ["W"]),
("WUB", ["W", "U", "B"]),
("WUBRG", ["W", "U", "B", "R", "G"]),
("Colorless", []),
("Mono-G", ["G"]),
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--rows", type=int, default=1000)
args = parser.parse_args()
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
_seed_db(conn, args.rows)
print(f"\n{'Commander':<14} {'Old ms':>8} {'New ms':>8} {'Old#':>6} {'New#':>6} {'Match':>6}")
print("-" * 60)
for label, ci in COMMANDERS:
# Old
t0 = time.perf_counter()
old_result = get_collection_for_colors_old(conn, ci)
old_ms = (time.perf_counter() - t0) * 1000
# New
t0 = time.perf_counter()
new_result = get_collection_for_colors(conn, ci)
new_ms = (time.perf_counter() - t0) * 1000
match = len(old_result) == len(new_result)
# NOTE: Colorless ([]) intentionally differs — old code had a bug that
# skipped the colour check entirely when color_identity was empty,
# returning ALL cards. The new SQL correctly restricts to colourless.
if not ci: # colorless commander
marker = "FIX" if len(new_result) < len(old_result) else "OK"
else:
marker = "OK" if match else "FAIL"
print(f" {label:<12} {old_ms:>7.2f} {new_ms:>7.2f} {len(old_result):>6} {len(new_result):>6} {marker:>6}")
if not match and ci: # only flag non-colorless mismatches
old_names = {c["name"] for c in old_result}
new_names = {c["name"] for c in new_result}
print(f" Only in old: {len(old_names - new_names)} cards")
print(f" Only in new: {len(new_names - old_names)} cards")
print()
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Services for Commander AI Lab."""
+81
View File
@@ -0,0 +1,81 @@
"""Card role detection, type classification, and deck composition analysis."""
import json
import re
def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
roles = []
ot = (oracle_text or "").lower()
tl = (type_line or "").lower()
kw_raw = keywords or []
if isinstance(kw_raw, str):
try:
kw_raw = json.loads(kw_raw)
except Exception:
kw_raw = []
kw = " ".join(str(k).lower() for k in kw_raw)
if ("add {" in ot or "add one mana" in ot
or ("search your library for a" in ot and "land" in ot)
or "treasure token" in ot or "add mana" in ot):
roles.append("Ramp")
if ("draw a card" in ot or "draw cards" in ot or "draws a card" in ot
or "draw two cards" in ot or "draw three cards" in ot
or re.search(r"draw \d+ cards?", ot)
or ("whenever" in ot and "draw" in ot)):
roles.append("Draw")
if ("destroy target" in ot or "exile target" in ot
or re.search(r"deals? \d+ damage to (target|any target)", ot)
or re.search(r"deals? x damage to (target|any target)", ot)
or "target creature gets -" in ot or "fights target" in ot):
roles.append("Removal")
if ("destroy all" in ot or "exile all" in ot
or re.search(r"all creatures get -\d+/-\d+", ot)
or re.search(r"each creature gets -\d+/-\d+", ot)
or ("deals" in ot and "to each creature" in ot)):
roles.append("Board Wipe")
if "search your library" in ot and "land" not in ot:
roles.append("Tutor")
if ("counter target" in ot or "counter that" in ot
or "counters" in kw):
roles.append("Counter")
if ("create" in ot and ("token" in ot or "tokens" in ot)):
roles.append("Token")
if ("each creature you control gets" in ot or "creatures you control get" in ot
or "all creatures you control" in ot or "anthem" in tl.lower()):
roles.append("Anthem")
if ("target creature gains" in ot and ("hexproof" in ot or "indestructible" in ot or "shroud" in ot))\
or ("protection" in kw):
roles.append("Protection")
if ("sacrifice" in ot and ("whenever" in ot or "you may" in ot)):
roles.append("Sacrifice")
if ("return" in ot and ("from your graveyard" in ot and "onto the battlefield" in ot)
or ("put" in ot and "from a graveyard" in ot and "onto the battlefield" in ot)):
roles.append("Recursion")
if (("graveyard" in ot and ("mill" in ot or "put" in ot and "into" in ot and "graveyard" in ot))
or "mill" in kw):
roles.append("Graveyard")
if ("gain" in ot and "life" in ot) or "lifelink" in ot or "lifelink" in kw:
roles.append("Lifegain")
if (re.search(r"deals? \d+ damage to (each|any|target) (opponent|player)", ot)
or "each opponent loses" in ot and "life" in ot
or "deals damage to each opponent" in ot):
roles.append("Burn")
if ("can't cast" in ot or "can't attack" in ot or "can't activate" in ot
or ("enters the battlefield tapped" in ot and "opponents" in ot)
or "each player can't" in ot or "players can't" in ot
or ("cost {" in ot and "more to cast" in ot)):
roles.append("Stax")
if ("flying" in kw or "trample" in kw or "menace" in kw or "shadow" in kw
or "fear" in kw or "intimidate" in kw
or "can't be blocked" in ot or "unblockable" in ot):
roles.append("Evasion")
if ("you win the game" in ot or "extra turn" in ot or "infinite" in ot
or "loses the game" in ot
or ("damage equal to" in ot and ("number" in ot or "total" in ot))):
roles.append("Finisher")
if ("untap all" in ot or ("copy" in ot and "spell" in ot)
or "take an extra" in ot
or ("double" in ot and ("damage" in ot or "counters" in ot or "tokens" in ot))):
roles.append("Combo")
return roles
+304
View File
@@ -0,0 +1,304 @@
"""SQLite database management and collection utility helpers."""
from __future__ import annotations
import io
import json
import logging
import re
import sqlite3
import threading
from pathlib import Path
from typing import Optional
log_collect = logging.getLogger("commander_ai_lab.collection")
# ══════════════════════════════════════════════════════════════
# Collection Database
# ══════════════════════════════════════════════════════════════
COLLECTION_DB_PATH = Path(__file__).parent.parent / "collection.db"
_db_local = threading.local()
def _get_db_conn() -> sqlite3.Connection:
"""Return the thread-local SQLite connection, creating it on first use."""
if not hasattr(_db_local, "conn") or _db_local.conn is None:
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
_db_local.conn = conn
return _db_local.conn
def _close_db_conn() -> None:
"""Close and discard the thread-local connection (call on thread teardown)."""
conn = getattr(_db_local, "conn", None)
if conn is not None:
try:
conn.close()
except Exception:
pass
_db_local.conn = None
def init_collection_db() -> None:
"""Create tables, indexes, and run column migrations.
Uses _get_db_conn() so the same thread-local connection that route
handlers will use is warmed up and fully migrated before any request
arrives. No separate connection is opened or closed here.
"""
conn = _get_db_conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS card_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
subtypes TEXT DEFAULT '',
is_legendary INTEGER DEFAULT 0,
is_basic INTEGER DEFAULT 0,
color_identity TEXT DEFAULT '',
cmc REAL DEFAULT 0,
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '',
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT '',
scryfall_id TEXT DEFAULT '',
tcgplayer_id TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS collection_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type_line TEXT DEFAULT '',
subtypes TEXT DEFAULT '',
is_legendary INTEGER DEFAULT 0,
is_basic INTEGER DEFAULT 0,
color_identity TEXT DEFAULT '',
cmc REAL DEFAULT 0,
mana_cost TEXT DEFAULT '',
oracle_text TEXT DEFAULT '',
keywords TEXT DEFAULT '',
power TEXT DEFAULT '',
toughness TEXT DEFAULT '',
rarity TEXT DEFAULT '',
set_name TEXT DEFAULT '',
edhrec_rank INTEGER DEFAULT 0,
tcg_price REAL DEFAULT 0,
salt_score REAL DEFAULT 0,
is_game_changer INTEGER DEFAULT 0,
category TEXT DEFAULT '',
scryfall_id TEXT DEFAULT '',
tcgplayer_id TEXT DEFAULT '',
quantity INTEGER DEFAULT 1,
finish TEXT DEFAULT 'NORMAL',
condition TEXT DEFAULT '',
language TEXT DEFAULT '',
notes TEXT DEFAULT '',
tags TEXT DEFAULT '',
set_code TEXT DEFAULT '',
collector_number TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ce_name ON collection_entries(name);
CREATE INDEX IF NOT EXISTS idx_ce_color_identity ON collection_entries(color_identity);
CREATE INDEX IF NOT EXISTS idx_ce_type_line ON collection_entries(type_line);
CREATE INDEX IF NOT EXISTS idx_ce_is_legendary ON collection_entries(is_legendary);
CREATE INDEX IF NOT EXISTS idx_ce_cmc ON collection_entries(cmc);
CREATE INDEX IF NOT EXISTS idx_ce_category ON collection_entries(category);
CREATE INDEX IF NOT EXISTS idx_ce_set_code ON collection_entries(set_code);
CREATE INDEX IF NOT EXISTS idx_ce_collector_number ON collection_entries(collector_number);
CREATE INDEX IF NOT EXISTS idx_ce_finish ON collection_entries(finish);
CREATE INDEX IF NOT EXISTS idx_ce_quantity ON collection_entries(quantity)
WHERE quantity > 0;
CREATE INDEX IF NOT EXISTS idx_cr_name ON card_records(name);
CREATE TABLE IF NOT EXISTS decks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
commander_scryfall_id TEXT DEFAULT '',
commander_name TEXT DEFAULT '',
color_identity TEXT DEFAULT '[]',
strategy_tag TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS deck_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deck_id INTEGER NOT NULL,
scryfall_id TEXT NOT NULL,
card_name TEXT DEFAULT '',
quantity INTEGER DEFAULT 1,
is_commander INTEGER DEFAULT 0,
role_tag TEXT DEFAULT '',
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
""")
conn.commit()
# ── Column migrations (additive only) ─────────────────────────────────
_migrate_columns = [
("mana_cost", "TEXT DEFAULT ''"),
("power", "TEXT DEFAULT ''"),
("toughness", "TEXT DEFAULT ''"),
("rarity", "TEXT DEFAULT ''"),
("set_name", "TEXT DEFAULT ''"),
("edhrec_rank", "INTEGER DEFAULT 0"),
]
existing_cols = {
row[1] for row in conn.execute("PRAGMA table_info(collection_entries)").fetchall()
}
for col_name, col_def in _migrate_columns:
if col_name not in existing_cols:
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
conn.commit()
log_collect.info(f" Collection DB ready: {COLLECTION_DB_PATH}")
# ══════════════════════════════════════════════════════════════
# Collection Utility Helpers
# ══════════════════════════════════════════════════════════════
_JSON_FIELDS = ("category", "color_identity", "subtypes", "keywords")
VALID_SORT_FIELDS = {
"name", "cmc", "tcg_price", "salt_score", "category", "color_identity",
"quantity", "type_line", "finish", "rarity", "set_code", "power",
"toughness", "edhrec_rank",
}
def _row_to_dict(row) -> dict:
if row is None:
return None
d = dict(row)
for f in _JSON_FIELDS:
if f in d and isinstance(d[f], str):
try:
d[f] = json.loads(d[f])
except (json.JSONDecodeError, TypeError):
d[f] = []
return d
def _snake_to_camel(s: str) -> str:
parts = s.split("_")
return parts[0] + "".join(p.capitalize() for p in parts[1:])
def _add_image_url(card: dict) -> dict:
scryfall_id = card.get("scryfall_id", "")
if scryfall_id:
card["imageUrl"] = f"https://api.scryfall.com/cards/{scryfall_id}?format=image&version=normal"
else:
card["imageUrl"] = None
for key in list(card.keys()):
if "_" in key:
camel = _snake_to_camel(key)
if camel not in card:
card[camel] = card[key]
return card
def _build_collection_filters(
q=None,
colors=None,
types=None,
isLegendary=None,
isBasic=None,
isGameChanger=None,
highSalt=None,
finish=None,
cmcMin=None,
cmcMax=None,
priceMin=None,
priceMax=None,
category=None,
rarity=None,
setCode=None,
powerMin=None,
powerMax=None,
toughMin=None,
toughMax=None,
keyword=None,
edhrecMin=None,
edhrecMax=None,
qtyMin=None,
qtyMax=None,
) -> tuple:
conditions = []
params = []
if q:
like_q = f"%{q}%"
conditions.append("(name LIKE ? OR type_line LIKE ? OR oracle_text LIKE ?)")
params.extend([like_q, like_q, like_q])
if colors:
for color in [c.strip().upper() for c in colors.split(",") if c.strip()]:
conditions.append("color_identity LIKE ?")
params.append(f"%{color}%")
if types:
tc = []
for t in [t.strip() for t in types.split(",") if t.strip()]:
tc.append("type_line LIKE ?")
params.append(f"%{t}%")
if tc:
conditions.append(f"({' OR '.join(tc)})")
if isLegendary is not None:
conditions.append("is_legendary = ?"); params.append(1 if isLegendary else 0)
if isBasic is not None:
conditions.append("is_basic = ?"); params.append(1 if isBasic else 0)
if isGameChanger is not None:
conditions.append("is_game_changer = ?"); params.append(1 if isGameChanger else 0)
if highSalt:
conditions.append("salt_score > 2.0")
if finish:
conditions.append("finish = ?"); params.append(finish.upper())
if cmcMin is not None:
conditions.append("cmc >= ?"); params.append(cmcMin)
if cmcMax is not None:
conditions.append("cmc <= ?"); params.append(cmcMax)
if priceMin is not None:
conditions.append("tcg_price >= ?"); params.append(priceMin)
if priceMax is not None:
conditions.append("tcg_price <= ?"); params.append(priceMax)
if category:
cc = []
for c in [c.strip() for c in category.split(",") if c.strip()]:
cc.append("category LIKE ?"); params.append(f"%{c}%")
if cc:
conditions.append(f"({' OR '.join(cc)})")
if rarity:
rl = [r.strip().lower() for r in rarity.split(",") if r.strip()]
if rl:
conditions.append(f"LOWER(rarity) IN ({','.join(['?']*len(rl))})"); params.extend(rl)
if setCode:
sl = [s.strip().upper() for s in setCode.split(",") if s.strip()]
if sl:
conditions.append(f"UPPER(set_code) IN ({','.join(['?']*len(sl))})"); params.extend(sl)
if powerMin is not None:
conditions.append("CAST(power AS REAL) >= ?"); params.append(float(powerMin))
if powerMax is not None:
conditions.append("CAST(power AS REAL) <= ?"); params.append(float(powerMax))
if toughMin is not None:
conditions.append("CAST(toughness AS REAL) >= ?"); params.append(float(toughMin))
if toughMax is not None:
conditions.append("CAST(toughness AS REAL) <= ?"); params.append(float(toughMax))
if keyword:
for kw in [k.strip() for k in keyword.split(",") if k.strip()]:
conditions.append("keywords LIKE ?"); params.append(f"%{kw}%")
if edhrecMin is not None:
conditions.append("edhrec_rank >= ?"); params.append(edhrecMin)
if edhrecMax is not None:
conditions.append("edhrec_rank <= ?"); params.append(edhrecMax)
if qtyMin is not None:
conditions.append("quantity >= ?"); params.append(qtyMin)
if qtyMax is not None:
conditions.append("quantity <= ?"); params.append(qtyMax)
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
return where_str, params
+265
View File
@@ -0,0 +1,265 @@
"""Deck service: .dck parsing, deck profile saving, deck loading."""
import json
import logging
import os
import re
from pathlib import Path
from typing import Optional
from fastapi import HTTPException
from models.state import CFG
from services.database import _get_db_conn, _row_to_dict
log_collect = logging.getLogger("lab.collection")
_TYPE_PRIORITY = ["Land", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Creature"]
_TYPE_TARGETS = {
"Land": [36, 38],
"Instant": [9, 11],
"Sorcery": [7, 9],
"Artifact": [9, 11],
"Creature": [20, 30],
"Enchantment": [5, 10],
"Planeswalker": [0, 5],
}
def _classify_card_type(type_line: str) -> str:
tl = type_line or ""
for t in _TYPE_PRIORITY:
if t in tl:
return t
return "Other"
def _get_deck_or_404(deck_id: int):
conn = _get_db_conn()
row = conn.execute("SELECT * FROM decks WHERE id = ?", (deck_id,)).fetchone()
if not row:
raise HTTPException(404, f"Deck {deck_id} not found")
d = dict(row)
try:
d["color_identity"] = json.loads(d.get("color_identity", "[]"))
except Exception:
d["color_identity"] = []
return d
def _compute_deck_analysis(deck_id: int) -> dict:
conn = _get_db_conn()
rows = conn.execute("""
SELECT dc.id, dc.scryfall_id, dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
ce.type_line, ce.oracle_text, ce.keywords, ce.cmc, ce.color_identity
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, keywords, cmc, color_identity
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
counts_by_type = {t: 0 for t in _TYPE_PRIORITY}
counts_by_type["Other"] = 0
mana_curve = {"0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0, "6+": 0}
color_pips = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
total_cards = 0
roles_count = {}
for row in rows:
r = dict(row)
qty = r.get("quantity", 1)
total_cards += qty
card_type = _classify_card_type(r.get("type_line", ""))
counts_by_type[card_type] = counts_by_type.get(card_type, 0) + qty
cmc = int(r.get("cmc", 0) or 0)
if card_type != "Land":
key = str(min(cmc, 6)) if cmc < 6 else "6+"
mana_curve[key] = mana_curve.get(key, 0) + qty
ci_raw = r.get("color_identity", "[]")
if isinstance(ci_raw, str):
try:
ci_raw = json.loads(ci_raw)
except Exception:
ci_raw = []
for c in (ci_raw or []):
if c in color_pips:
color_pips[c] += qty
role = r.get("role_tag") or "Unknown"
roles_count[role] = roles_count.get(role, 0) + qty
return {
"total_cards": total_cards,
"counts_by_type": counts_by_type,
"mana_curve": mana_curve,
"color_pips": color_pips,
"total_cards": total_cards,
"roles": roles_count,
}
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
analysis = _compute_deck_analysis(deck_id)
current = analysis["counts_by_type"].get(card_type, 0)
target_max = _TYPE_TARGETS.get(card_type, [0, 9999])[1]
return (current + count_to_add) <= target_max
def _to_edhrec_slug(name: str) -> str:
slug = name.lower()
slug = re.sub(r"[',.]" , "", slug)
slug = re.sub(r"[^a-z0-9]+", "-", slug)
return slug.strip("-")
def parse_dck_file(deck_path: str) -> dict:
"""Parse a Forge .dck file into {name, commanders, mainboard, colorIdentity, totalCards}."""
path = Path(deck_path)
if not path.exists():
return {"error": f"File not found: {deck_path}"}
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
name = path.stem
section = "main"
commanders: dict = {}
mainboard: dict = {}
commander_name = None
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
if line == "[metadata]":
section = "metadata"; continue
if line.startswith("Name="):
name = line.split("=", 1)[1].strip(); continue
if line == "[Commander]":
section = "commander"; continue
if line == "[Main]":
section = "main"; continue
if line.lower().startswith("sideboard") or line == "[Sideboard]":
section = "sideboard"; continue
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
clean = re.sub(r"\s*\*\.\*$", "", clean).strip()
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
if not card_name:
continue
if section == "commander":
commanders[card_name] = qty
if not commander_name:
commander_name = card_name
elif section != "sideboard":
mainboard[card_name] = qty
if commander_name:
name = f"{commander_name} - Text Import"
total = sum(commanders.values()) + sum(mainboard.values())
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
def _save_profile_to_dck(profile: dict) -> Path:
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
for name, qty in profile.get("commanders", {}).items():
lines.append(f"{qty} {name}")
lines += ["", "[Main]"]
for name, qty in profile.get("mainboard", {}).items():
lines.append(f"{qty} {name}")
content = "\n".join(lines)
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
if not safe_name:
safe_name = "imported_deck"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
log_collect.info(f" Saved .dck: {out_path}")
return out_path
def _load_deck_cards_by_name(deck_name: str) -> list:
"""Load cards from a deck by name, returning list of card dicts."""
conn = _get_db_conn()
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
if not deck_row:
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
COALESCE(ce.type_line, cr.type_line, '') as type_line,
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
COALESCE(ce.mana_cost, '', '') as mana_cost,
COALESCE(ce.power, '', '') as power,
COALESCE(ce.toughness, '', '') as toughness,
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
LEFT JOIN (
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
FROM card_records GROUP BY name
) cr ON cr.name = dc.card_name
WHERE dc.deck_id = ?
""", (deck_id,)).fetchall()
return [_row_to_dict(r) for r in rows]
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
def _build_dck_lines(
deck_name: str,
commander_name: str,
cards: list[dict],
*,
resolve_substitutes: bool = False,
) -> list[str]:
"""Build Forge .dck file lines from deck name, commander, and card list."""
lines: list[str] = [
"[metadata]",
f"Name={deck_name}",
"",
"[Commander]",
f"1 {commander_name}",
"",
"[Main]",
]
for card in cards:
cname = card.get("name", "")
if resolve_substitutes:
if card.get("status") == "substituted" and card.get("selected_substitute"):
cname = card["selected_substitute"]
if cname and cname != commander_name:
lines.append(f"{card.get('count', card.get('quantity', 1))} {cname}")
return lines
def _write_dck_file(
deck_name: str,
commander_name: str,
cards: list[dict],
decks_dir: str | None = None,
*,
fallback_id: int | str = 0,
resolve_substitutes: bool = False,
) -> str | None:
"""Write a Forge .dck file to decks_dir. Returns path on success, None otherwise."""
if not decks_dir:
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
return None
safe_name = re.sub(r"[^a-zA-Z0-9_\-\s]", "", deck_name).strip().replace(" ", "_")
if not safe_name:
safe_name = f"Deck_{fallback_id}"
dck_path = os.path.join(decks_dir, f"{safe_name}.dck")
lines = _build_dck_lines(
deck_name, commander_name, cards,
resolve_substitutes=resolve_substitutes,
)
with open(dck_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
log_deckgen.info(f" Exported .dck: {dck_path}")
return dck_path
+591
View File
@@ -0,0 +1,591 @@
"""Forge batch simulation runner: Java discovery, command building, process management."""
import asyncio
import json
import logging
import os
import re
import subprocess
import sys
import threading
import time
from datetime import datetime as _datetime
from pathlib import Path
from typing import Optional
from models.state import CFG, BatchState
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
log = logging.getLogger("lab.batch")
log_sim = logging.getLogger("commander_ai_lab.sim")
# Global DeepSeek brain instance (lazy-initialized)
_deepseek_brain = None
_deepseek_lock = threading.Lock()
# ── Global watchdog: single thread monitors all active batches ──
_watchdog_registry: dict[str, dict] = {} # batch_id -> {state, proc, last_activity}
_watchdog_lock = threading.Lock()
_watchdog_started = False
_STALL_LIMIT = 600 # 10 min
def _global_watchdog_loop():
"""Single daemon thread that checks all active batches for stalls."""
while True:
time.sleep(5)
with _watchdog_lock:
finished = []
for bid, entry in _watchdog_registry.items():
proc = entry["proc"]
if proc.poll() is not None:
finished.append(bid)
continue
if (time.monotonic() - entry["last_activity"]) > _STALL_LIMIT:
log.warning(f"Stall detected for batch {bid}, killing process")
entry["state"].log_lines.append("[WATCHDOG] Process stalled. Killed.")
proc.kill()
entry["state"].running = False
entry["state"].error = "Stall detected (10 min no output)"
finished.append(bid)
for bid in finished:
del _watchdog_registry[bid]
def _ensure_watchdog_started():
global _watchdog_started
if not _watchdog_started:
with _watchdog_lock:
if not _watchdog_started:
t = threading.Thread(target=_global_watchdog_loop, daemon=True)
t.start()
_watchdog_started = True
def _register_batch(batch_id: str, state, proc):
_ensure_watchdog_started()
with _watchdog_lock:
_watchdog_registry[batch_id] = {
"state": state, "proc": proc, "last_activity": time.monotonic()
}
def _touch_batch(batch_id: str):
with _watchdog_lock:
if batch_id in _watchdog_registry:
_watchdog_registry[batch_id]["last_activity"] = time.monotonic()
def _unregister_batch(batch_id: str):
with _watchdog_lock:
_watchdog_registry.pop(batch_id, None)
# ── Java 17 Discovery ───────────────────────────────────────────────
# Ordered list of directories to scan for JDK 17 installations.
# Covers: Eclipse Temurin, Oracle, Amazon Corretto, Microsoft, Azul Zulu, SAP.
_JAVA17_SEARCH_DIRS = [
r'C:\Program Files\Eclipse Adoptium',
r'C:\Program Files\Java',
r'C:\Program Files\Amazon Corretto',
r'C:\Program Files\Microsoft',
r'C:\Program Files\Zulu',
r'C:\Program Files\BellSoft',
r'C:\Program Files\SapMachine',
]
def _find_java17() -> str:
"""
Search well-known JDK install directories for a Java 17 binary.
Falls back to JAVA17_HOME env var, then JAVA_HOME, then warns and returns 'java'.
Returns the absolute path to java.exe if found, otherwise 'java'.
"""
# 1. Explicit override via env var
env_override = os.environ.get('JAVA17_HOME', '').strip()
if env_override:
candidate = os.path.join(env_override, 'bin', 'java.exe')
if os.path.isfile(candidate):
log.info(f"Java 17 found via JAVA17_HOME: {candidate}")
return candidate
log.warning(f"JAVA17_HOME set to '{env_override}' but java.exe not found there.")
# 2. Scan vendor directories
for d in _JAVA17_SEARCH_DIRS:
if not os.path.isdir(d):
continue
try:
children = os.listdir(d)
except OSError:
continue
for child in sorted(children): # sorted = deterministic, picks lowest 17.x
if re.match(r'jdk-?17', child, re.IGNORECASE) or re.match(r'temurin-17', child, re.IGNORECASE):
candidate = os.path.join(d, child, 'bin', 'java.exe')
if os.path.isfile(candidate):
log.info(f"Java 17 found: {candidate}")
return candidate
# 3. JAVA_HOME fallback — only accept if it IS version 17
java_home = os.environ.get('JAVA_HOME', '').strip()
if java_home:
candidate = os.path.join(java_home, 'bin', 'java.exe')
if os.path.isfile(candidate):
try:
result = subprocess.run(
[candidate, '-version'],
capture_output=True, text=True, timeout=5
)
version_output = result.stderr or result.stdout
if 'version "17' in version_output:
log.info(f"Java 17 found via JAVA_HOME: {candidate}")
return candidate
else:
log.warning(
f"JAVA_HOME points to '{java_home}' but it is not Java 17. "
f"Version output: {version_output.strip()!r}"
)
except Exception as e:
log.warning(f"Could not probe JAVA_HOME java version: {e}")
# 4. Nothing found — warn loudly so the log surfaces the issue
log.error(
"Java 17 not found! Forge simulation requires Java 17. "
"Install Eclipse Temurin 17 (winget install EclipseAdoptium.Temurin.17.JDK) "
"or set the JAVA17_HOME environment variable to your JDK 17 install directory. "
"Falling back to system 'java' which may be the wrong version."
)
return 'java'
_JAVA17_PATH = None
def get_java17() -> str:
global _JAVA17_PATH
if _JAVA17_PATH is None:
_JAVA17_PATH = _find_java17()
return _JAVA17_PATH
def reset_java17_cache():
"""Force re-discovery of Java 17 path (useful after installing JDK at runtime)."""
global _JAVA17_PATH
_JAVA17_PATH = None
def build_java_command(
decks: list, num_games: int, threads: int, seed: Optional[int],
clock: int, output_path: str, use_learned_policy: bool = False,
policy_server: str = "http://localhost:8080", policy_style: str = "midrange",
policy_greedy: bool = False, ai_simplified: bool = False,
ai_think_time_ms: int = -1, max_queue_depth: int = -1,
) -> list:
java17 = get_java17()
lab_jar = CFG.lab_jar
forge_jar = CFG.forge_jar
forge_dir = CFG.forge_dir
if not lab_jar or not os.path.isfile(lab_jar):
log.error(f"Lab JAR not found at '{lab_jar}'. Run 'mvn package' first.")
raise FileNotFoundError(f"Lab JAR not found: {lab_jar}")
cmd = [
java17, '-jar', lab_jar,
'--forge-jar', forge_jar,
'--forge-dir', forge_dir,
'--games', str(num_games),
'--threads', str(threads),
'--clock', str(clock),
'--output', output_path,
]
# Map deck list to --deck1 .. --deck4
for i, d in enumerate(decks[:4], start=1):
cmd += [f'--deck{i}', str(d)]
if seed is not None:
cmd += ['--seed', str(seed)]
if use_learned_policy:
cmd += ['--policy', policy_style, '--policyServer', policy_server]
if policy_greedy:
cmd += ['--policyGreedy']
if ai_simplified:
cmd += ['--aiSimplified']
if ai_think_time_ms > 0:
cmd += ['--aiThinkTimeMs', str(ai_think_time_ms)]
if max_queue_depth > 0:
cmd += ['--maxQueueDepth', str(max_queue_depth)]
return cmd
def _run_process_blocking(state: BatchState, cmd: list):
"""Run Forge subprocess, parse progress, generate deck reports."""
log.info(f"Running: {' '.join(cmd)}")
# Ensure Forge subprocesses use Java 17
env = os.environ.copy()
java17 = get_java17()
if java17 != 'java':
java17_bin = os.path.dirname(java17)
java17_home = os.path.dirname(java17_bin)
env['JAVA_HOME'] = java17_home
env['PATH'] = java17_bin + os.pathsep + env.get('PATH', '')
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env,
)
state.process = proc
except Exception as e:
state.running = False
state.error = str(e)
log.error(f"Failed to start subprocess: {e}")
return
_register_batch(state.batch_id, state, proc)
for line in proc.stdout:
_touch_batch(state.batch_id)
line = line.rstrip()
state.log_lines.append(line)
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
if gm:
state.completed_games = int(gm.group(1))
state.total_games = int(gm.group(2))
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
if pm:
state.sims_per_sec = float(pm.group(1))
proc.wait()
_unregister_batch(state.batch_id)
elapsed = (_datetime.now() - state.start_time).total_seconds() * 1000
state.elapsed_ms = int(elapsed)
if proc.returncode != 0:
state.running = False
# Include last few log lines in error for easier diagnosis
tail = ' | '.join(state.log_lines[-5:]) if state.log_lines else '(no output)'
state.error = f"Exit code {proc.returncode} — last output: {tail}"
else:
state.running = False
state.completed_games = state.total_games
async def run_batch_subprocess(
state: BatchState,
decks: list,
num_games: int,
threads: int,
seed: Optional[int] = None,
clock: int = 6000,
output_path: str = "results",
use_learned_policy: bool = False,
policy_style: str = "midrange",
policy_greedy: bool = False,
ai_simplified: bool = False,
ai_think_time_ms: int = -1,
max_queue_depth: int = -1,
):
"""Async wrapper that runs Forge subprocess in executor."""
try:
policy_server = f"http://localhost:{CFG.port}"
cmd = build_java_command(
decks, num_games, threads, seed, clock, output_path,
use_learned_policy=use_learned_policy,
policy_server=policy_server,
policy_style=policy_style,
policy_greedy=policy_greedy,
ai_simplified=ai_simplified,
ai_think_time_ms=ai_think_time_ms,
max_queue_depth=max_queue_depth,
)
log.info(f"Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
except Exception as e:
state.error = str(e)
state.running = False
log.error(f"Batch {state.batch_id} ERROR: {e}")
def _get_deepseek_brain():
"""Get or create the global DeepSeek brain instance."""
global _deepseek_brain
if _deepseek_brain is None:
with _deepseek_lock:
if _deepseek_brain is None:
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
cfg = DeepSeekConfig(
api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"),
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "30.0")),
)
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
cfg.log_dir = os.path.normpath(cfg.log_dir)
_deepseek_brain = DeepSeekBrain(cfg)
except Exception as e:
log_sim.error(f'Failed to initialize brain: {e}')
return None
return _deepseek_brain
def _run_deepseek_batch_thread(
state: BatchState,
deck_names: list,
num_games: int,
output_path: str,
):
"""Run batch simulation using Python sim engine + DeepSeek AI opponent."""
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.rules import enrich_card
state.log_lines.append('[DeepSeek Batch] Initializing DeepSeek brain...')
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
if not brain or not brain._connected:
state.error = 'DeepSeek LLM not connected. Go to Simulator > DeepSeek and connect first.'
state.running = False
return
state.log_lines.append(f'[DeepSeek Batch] Connected to {brain.config.model}')
deck_win_rates = {}
try:
results_dir = CFG.results_dir
if os.path.isdir(results_dir):
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('batch-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r') as rf:
past = json.loads(rf.read())
for dd in past.get('decks', []):
dname = dd.get('deckName', '')
wr = dd.get('winRate')
if dname and wr is not None and dname not in deck_win_rates:
deck_win_rates[dname] = float(wr)
except Exception:
pass
except Exception:
pass
loaded_decks = {}
deck_meta = {}
for dn in deck_names:
raw_cards = _load_deck_cards_by_name(dn)
if not raw_cards:
state.log_lines.append(f'[DeepSeek Batch] WARNING: Could not load deck "{dn}", skipping.')
continue
commander_name = ''
color_identity_set = set()
deck_objs = []
for cd in raw_cards:
c = Card(name=(cd.get('card_name') or cd.get('name', '')))
if cd.get('type_line'): c.type_line = cd['type_line']
if cd.get('cmc'): c.cmc = float(cd['cmc'])
if cd.get('power') and cd.get('toughness'):
c.power = str(cd['power'])
c.toughness = str(cd['toughness'])
c.pt = c.power + '/' + c.toughness
if cd.get('oracle_text'): c.oracle_text = cd['oracle_text']
if cd.get('mana_cost'): c.mana_cost = cd['mana_cost']
if cd.get('keywords'):
kw = cd['keywords']
if isinstance(kw, str):
try: kw = json.loads(kw)
except Exception: kw = []
if isinstance(kw, list): c.keywords = kw
if cd.get('is_commander'):
c.is_commander = True
commander_name = (cd.get('card_name') or cd.get('name', ''))
ci_str = cd.get('color_identity', '')
if ci_str:
try:
ci_parsed = json.loads(ci_str) if isinstance(ci_str, str) else ci_str
if isinstance(ci_parsed, list):
for color in ci_parsed:
color_identity_set.add(color)
except Exception:
pass
enrich_card(c)
deck_objs.append(c)
creature_count = sum(1 for c in deck_objs if c.is_creature())
removal_count = sum(1 for c in deck_objs if c.is_removal)
ramp_count = sum(1 for c in deck_objs if c.is_ramp)
avg_cmc = sum(c.cmc or 0 for c in deck_objs if not c.is_land()) / max(sum(1 for c in deck_objs if not c.is_land()), 1)
oracle_all = ' '.join((c.oracle_text or '').lower() for c in deck_objs)
has_combo_text = any(kw in oracle_all for kw in ['you win the game', 'infinite', 'extra turn'])
if has_combo_text:
archetype = 'combo'
elif creature_count >= 30 and avg_cmc <= 2.8:
archetype = 'aggro'
elif removal_count >= 10 or (creature_count <= 18 and avg_cmc >= 3.2):
archetype = 'control'
else:
archetype = 'midrange'
color_identity_list = sorted(list(color_identity_set))
deck_meta[dn] = {
'commander_name': commander_name,
'color_identity': color_identity_list,
'archetype': archetype,
'win_rate': deck_win_rates.get(dn),
}
loaded_decks[dn] = deck_objs
cmdr_info = f' (Commander: {commander_name})' if commander_name else ''
wr_info = f' [History: {deck_win_rates[dn]:.0f}% WR]' if dn in deck_win_rates else ''
state.log_lines.append(f'[DeepSeek Batch] Loaded deck "{dn}" ({len(deck_objs)} cards, {archetype}){cmdr_info}{wr_info}')
if not loaded_decks:
state.error = 'No decks could be loaded.'
state.running = False
return
deck_list = list(loaded_decks.keys())
games_per_deck = max(1, num_games // len(deck_list))
total_games = games_per_deck * len(deck_list)
state.total_games = total_games
state.log_lines.append(f'[DeepSeek Batch] Running {games_per_deck} games per deck \u00d7 {len(deck_list)} decks = {total_games} total')
engine = DeepSeekGameEngine(
brain=brain,
ai_player_index=0,
max_turns=25,
record_log=True,
ml_log=True,
)
start_time = time.time()
all_deck_results = []
completed = 0
for deck_name in deck_list:
deck_a = loaded_decks[deck_name]
from commander_ai_lab.lab.experiments import _generate_training_deck
deck_stats = {
'deckName': deck_name,
'wins': 0, 'losses': 0, 'totalGames': games_per_deck,
'totalTurns': 0, 'totalDamageDealt': 0, 'totalDamageReceived': 0,
'totalSpellsCast': 0, 'totalCreaturesPlayed': 0,
'games': [],
}
meta = deck_meta.get(deck_name, {})
dk_archetype = meta.get('archetype', 'midrange')
dk_commander = meta.get('commander_name', '')
dk_colors = meta.get('color_identity', [])
dk_win_rate = meta.get('win_rate')
for g in range(games_per_deck):
try:
deck_b = _generate_training_deck()
game_id = f'ds-{state.batch_id}-{deck_name[:12]}-g{g+1}'
result = engine.run(
deck_a, deck_b,
name_a=deck_name + ' (AI)',
name_b='Training Opponent',
game_id=game_id, archetype=dk_archetype,
commander_name=dk_commander,
color_identity=dk_colors,
win_rate=dk_win_rate,
)
gd = result.to_dict()
gd['gameNumber'] = g + 1
deck_stats['games'].append(gd)
if result.winner == 0:
deck_stats['wins'] += 1
else:
deck_stats['losses'] += 1
deck_stats['totalTurns'] += result.turns
if result.player_a_stats:
deck_stats['totalDamageDealt'] += result.player_a_stats.damage_dealt
deck_stats['totalDamageReceived'] += result.player_a_stats.damage_received
deck_stats['totalSpellsCast'] += result.player_a_stats.spells_cast
deck_stats['totalCreaturesPlayed'] += result.player_a_stats.creatures_played
state.log_lines.append(
f'[Game {completed + 1}/{total_games}] {deck_name} (AI-piloted) \u2192 '
f'{"WIN" if result.winner == 0 else "LOSS"} (turn {result.turns})'
)
except Exception as ge:
state.log_lines.append(f'[Game {completed + 1}/{total_games}] ERROR: {ge}')
deck_stats['games'].append({'error': str(ge), 'gameNumber': g + 1})
completed += 1
state.completed_games = completed
n = deck_stats['totalGames']
deck_stats['winRate'] = round(deck_stats['wins'] / n * 100, 1) if n > 0 else 0.0
deck_stats['avgTurns'] = round(deck_stats['totalTurns'] / n, 1) if n > 0 else 0.0
deck_stats['avgDamageDealt'] = round(deck_stats['totalDamageDealt'] / n, 1) if n > 0 else 0.0
deck_stats['avgDamageReceived'] = round(deck_stats['totalDamageReceived'] / n, 1) if n > 0 else 0.0
deck_stats['avgSpellsCast'] = round(deck_stats['totalSpellsCast'] / n, 1) if n > 0 else 0.0
deck_stats['avgCreaturesPlayed'] = round(deck_stats['totalCreaturesPlayed'] / n, 1) if n > 0 else 0.0
deck_stats['archetype'] = dk_archetype
deck_stats['commander'] = dk_commander
deck_stats['colorIdentity'] = dk_colors
if dk_win_rate is not None:
deck_stats['priorWinRate'] = dk_win_rate
all_deck_results.append(deck_stats)
elapsed = time.time() - start_time
ds_stats = brain.get_stats() if brain else {}
batch_result = {
'metadata': {
'batchId': state.batch_id,
'timestamp': _datetime.now().isoformat(),
'completedGames': completed,
'threads': 1,
'elapsedMs': int(elapsed * 1000),
'engine': 'deepseek',
'model': brain.config.model if brain else 'unknown',
},
'decks': all_deck_results,
'deepseekStats': ds_stats,
}
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
ml_jsonl_path = os.path.join(CFG.results_dir, f'ml-decisions-ds-{state.batch_id}.jsonl')
os.makedirs(os.path.dirname(ml_jsonl_path) or '.', exist_ok=True)
with open(ml_jsonl_path, 'w', encoding='utf-8') as mf:
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
state.log_lines.append(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {os.path.basename(ml_jsonl_path)}')
else:
state.log_lines.append('[ML Data] No decision snapshots captured (0 decisions)')
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(batch_result, f, indent=2, default=str)
state.result_path = output_path
state.elapsed_ms = int(elapsed * 1000)
state.running = False
state.completed_games = total_games
state.log_lines.append(f'[DeepSeek Batch] Complete: {completed} games in {elapsed:.1f}s')
for ds in all_deck_results:
state.log_lines.append(f' {ds["deckName"]}: {ds["wins"]}W-{ds["losses"]}L ({ds["winRate"]}% WR)')
log_sim.info(f'Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
except Exception as e:
import traceback
state.error = str(e)
state.running = False
state.log_lines.append(f'[DeepSeek Batch] FATAL: {e}')
traceback.print_exc()
+193
View File
@@ -0,0 +1,193 @@
"""URL-based deck import helpers: Archidekt, EDHREC, text decklist parsing."""
import json
import logging
import os
import re
import time
from pathlib import Path
from urllib.request import urlopen, Request
from models.state import CFG
from services.deck_service import _to_edhrec_slug
from services.scryfall import _API_HEADERS
log_collect = logging.getLogger("commander_ai_lab.collection")
def _http_get(url: str) -> str:
with urlopen(Request(url, headers=_API_HEADERS), timeout=30) as resp:
return resp.read().decode("utf-8")
def _fetch_archidekt_deck(deck_id: str) -> dict:
url = f"https://archidekt.com/api/decks/{deck_id}/"
data = json.loads(_http_get(url))
profile = {
"name": data.get("name", f"Archidekt {deck_id}"),
"commander": None,
"source": "Archidekt",
"sourceUrl": f"https://archidekt.com/decks/{deck_id}",
"commanders": {},
"mainboard": {},
"colorIdentity": [],
"totalCards": 0,
}
for card_entry in data.get("cards", []):
qty = card_entry.get("quantity", 1)
oracle = card_entry.get("card", {}).get("oracleCard", {})
card_name = oracle.get("name", "Unknown")
is_commander = any(c.lower() == "commander" for c in card_entry.get("categories", []))
if is_commander:
profile["commanders"][card_name] = qty
if not profile["commander"]:
profile["commander"] = card_name
if oracle.get("colorIdentity"):
profile["colorIdentity"] = oracle["colorIdentity"]
else:
profile["mainboard"][card_name] = qty
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
if not profile["name"] or profile["name"] == f"Archidekt {deck_id}":
profile["name"] = f"{profile['commander']} \u2014 Archidekt"
return profile
def _fetch_edhrec_average(commander_name: str) -> dict:
slug = _to_edhrec_slug(commander_name)
url = f"https://json.edhrec.com/pages/average-decks/{slug}.json"
data = json.loads(_http_get(url))
container = data.get("container", {})
json_dict = container.get("json_dict", {})
card_info = json_dict.get("card", {})
real_name = card_info.get("name", commander_name.title())
profile = {
"name": f"{real_name} \u2014 EDHREC Average",
"commander": real_name,
"source": "EDHREC Average",
"sourceUrl": f"https://edhrec.com/average-decks/{slug}",
"commanders": {real_name: 1},
"mainboard": {},
"colorIdentity": card_info.get("color_identity", []),
"sampleSize": data.get("num_decks_avg"),
"totalCards": 0,
}
for cl in json_dict.get("cardlists", []):
for cv in cl.get("cardviews", []):
card_name = cv.get("name", "")
if card_name:
profile["mainboard"][card_name] = 1
archidekt_data = data.get("archidekt", [])
if archidekt_data:
basic_names = []
for cl in json_dict.get("cardlists", []):
if cl.get("tag") == "basics":
for cv in cl.get("cardviews", []):
basic_names.append(cv.get("name", ""))
basic_quantities = [e["q"] for e in archidekt_data if e.get("q", 1) > 1]
for i, name in enumerate(basic_names):
if i < len(basic_quantities):
profile["mainboard"][name] = basic_quantities[i]
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
return profile
def _import_from_url(url: str) -> dict:
url = url.strip()
if "archidekt.com/decks/" in url:
m = re.search(r"/decks/(\d+)", url)
if not m:
raise ValueError("Could not parse Archidekt deck ID from URL")
return _fetch_archidekt_deck(m.group(1))
if "edhrec.com/average-decks/" in url:
slug = re.search(r"/average-decks/([^/?#]+)", url).group(1)
return _fetch_edhrec_average(slug.replace("-", " "))
if "edhrec.com/commanders/" in url:
slug = re.search(r"/commanders/([^/?#]+)", url).group(1)
return _fetch_edhrec_average(slug.replace("-", " "))
raise ValueError(f"Unsupported URL: {url}. Supported: archidekt.com/decks/..., edhrec.com/average-decks/..., edhrec.com/commanders/...")
def _parse_text_decklist(text: str, commander_override: str = None) -> dict:
profile = {
"name": "Text Import",
"commander": None,
"source": "Text Import",
"sourceUrl": None,
"commanders": {},
"mainboard": {},
"totalCards": 0,
}
section = "main"
for line in text.strip().split("\n"):
line = line.strip()
if not line or line.startswith("//") or line.startswith("#"):
continue
if line.lower().startswith("commander") or line == "[Commander]":
section = "commander"; continue
if line.lower().startswith("main") or line.lower().startswith("deck") or line == "[Main]":
section = "main"; continue
if line.lower().startswith("sideboard") or line == "[Sideboard]":
section = "sideboard"; continue
clean = re.sub(r"\(\w+\)\s*\d*$", "", line).strip()
clean = re.sub(r"\s*\*.*$", "", clean).strip()
m = re.match(r"^(\d+)x?\s+(.+)$", clean)
qty, card_name = (int(m.group(1)), m.group(2).strip()) if m else (1, clean)
if not card_name:
continue
if section == "commander":
profile["commanders"][card_name] = qty
if not profile["commander"]:
profile["commander"] = card_name
elif section != "sideboard":
profile["mainboard"][card_name] = qty
if commander_override:
profile["commander"] = commander_override
if not profile["commanders"]:
if commander_override in profile["mainboard"]:
qty = profile["mainboard"].pop(commander_override)
profile["commanders"][commander_override] = qty
else:
profile["commanders"][commander_override] = 1
if profile["commander"]:
profile["name"] = f"{profile['commander']} \u2014 Text Import"
profile["totalCards"] = sum(profile["commanders"].values()) + sum(profile["mainboard"].values())
return profile
def _save_profile_to_dck(profile: dict) -> Path:
lines = ["[metadata]", f"Name={profile.get('name', 'Imported Deck')}", "", "[Commander]"]
for name, qty in profile.get("commanders", {}).items():
lines.append(f"{qty} {name}")
lines += ["", "[Main]"]
for name, qty in profile.get("mainboard", {}).items():
lines.append(f"{qty} {name}")
content = "\n".join(lines)
safe_name = re.sub(r"[^a-zA-Z0-9 _-]", "", profile.get("name", "imported")).replace(" ", "_").strip()
if not safe_name:
safe_name = "imported_deck"
save_dir = CFG.forge_decks_dir
if not save_dir or not os.path.isdir(save_dir):
save_dir = os.path.join(Path(__file__).parent.parent, "imported-decks")
os.makedirs(save_dir, exist_ok=True)
out_path = Path(save_dir) / f"{safe_name}.dck"
out_path.write_text(content, encoding="utf-8")
log_collect.info(f" Saved .dck: {out_path}")
return out_path
# EDHREC in-memory cache
_edhrec_cache: dict = {}
_EDHREC_CACHE_TTL = 3600
def _edhrec_cache_get(key: str):
entry = _edhrec_cache.get(key)
if not entry:
return None
if time.time() - entry["ts"] > _EDHREC_CACHE_TTL:
del _edhrec_cache[key]
return None
return entry["data"]
def _edhrec_cache_set(key: str, data):
_edhrec_cache[key] = {"data": data, "ts": time.time()}
+110
View File
@@ -0,0 +1,110 @@
"""Collection import service: CSV/text parsing helpers."""
import csv
import io
import re
from typing import Optional
def _parse_finish(raw: str) -> str:
if not raw:
return "NORMAL"
r = raw.strip().lower()
if r in ("etched", "foil etched", "foil_etched", "etched foil"):
return "ETCHED"
if r in ("yes", "foil", "true", "1"):
return "FOIL"
return "NORMAL"
def _parse_text_line(line: str) -> dict:
line = line.strip()
if not line or line.startswith("#") or line.startswith("//"):
return {}
result = {"name": "", "quantity": 1, "set_code": "", "collector_number": ""}
qty_match = re.match(r"^(\d+)x?\s+", line)
if qty_match:
result["quantity"] = int(qty_match.group(1))
line = line[qty_match.end():]
bracket_match = re.search(r"\[([A-Za-z0-9]+):(\S+)\]\s*$", line)
if bracket_match:
result["set_code"] = bracket_match.group(1).lower()
result["collector_number"] = bracket_match.group(2)
result["name"] = line[:bracket_match.start()].strip()
return result
paren_coll_match = re.search(r"\(([A-Za-z0-9]+)\)\s+(\S+)\s*$", line)
if paren_coll_match:
result["set_code"] = paren_coll_match.group(1).lower()
result["collector_number"] = paren_coll_match.group(2)
result["name"] = line[:paren_coll_match.start()].strip()
return result
paren_match = re.search(r"\(([A-Za-z0-9]+)\)\s*$", line)
if paren_match:
result["set_code"] = paren_match.group(1).lower()
result["name"] = line[:paren_match.start()].strip()
return result
result["name"] = line.strip()
return result
def _auto_infer_mapping(headers: list) -> dict:
mapping = {}
header_lower = {h: h.lower().strip() for h in headers}
field_patterns = {
"name": ["name", "card name", "card_name", "cardname", "title"],
"quantity": ["quantity", "qty", "count", "amount", "number", "#"],
"set_code": ["set", "set code", "set_code", "edition", "set_name", "edition code"],
"collector_number": ["collector number", "collector_number", "collectornumber", "col #", "col#", "number", "card number"],
"finish": ["finish", "foil", "printing", "treatment"],
"condition": ["condition", "cond", "grade"],
"language": ["language", "lang", "locale"],
"notes": ["notes", "note", "comments", "comment"],
"tags": ["tags", "tag", "labels", "label"],
}
for header, lower_h in header_lower.items():
for field, patterns in field_patterns.items():
if lower_h in patterns:
mapping[header] = field
break
return mapping
def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> list:
rows = []
if (source or "").upper() == "TEXT":
for line in content.splitlines():
parsed = _parse_text_line(line)
if parsed.get("name"):
rows.append(parsed)
return rows
reader = csv.DictReader(io.StringIO(content))
headers = reader.fieldnames or []
col_map = {h: (mapping.get(h, "") if mapping else "") for h in headers}
if not mapping:
col_map = _auto_infer_mapping(headers)
col_map = {h: (v.lower() if isinstance(v, str) else v) for h, v in col_map.items()}
for csv_row in reader:
row = {"name": "", "quantity": 1, "set_code": "", "collector_number": "",
"finish": "NORMAL", "condition": "", "language": "", "notes": "", "tags": ""}
for header, field in col_map.items():
val = csv_row.get(header, "").strip()
if not val or field == "ignore" or not field:
continue
if field == "name":
val = re.sub(r"\s*\(foil(?: etched)?\)\s*$", "", val, flags=re.IGNORECASE).strip()
row["name"] = val
elif field == "quantity":
try:
row["quantity"] = int(float(val))
except ValueError:
row["quantity"] = 1
elif field in ("set_code", "set_code_secondary"):
row["set_code"] = val.lower()
elif field in ("collector_number", "collector_number_secondary"):
row["collector_number"] = val
elif field == "finish":
row["finish"] = _parse_finish(val)
elif field in ("condition", "language", "notes", "tags"):
row[field] = val
if row["name"]:
rows.append(row)
return rows
+230
View File
@@ -0,0 +1,230 @@
"""Precon deck download, caching, and conversion logic."""
from __future__ import annotations
import json
import logging
import os
import re
import time
from collections import Counter
from pathlib import Path
from urllib.request import urlopen, Request
log = logging.getLogger("commander_ai_lab.api")
# Fallback default — used only when CFG.precon_dir has not been resolved yet.
_DEFAULT_PRECON_DIR = Path(__file__).parent.parent / "precon-decks"
PRECON_INDEX: list[dict] = []
GITHUB_PRECON_URL = (
"https://raw.githubusercontent.com/taw/magic-preconstructed-decks-data/"
"master/decks_v2.json"
)
PRECON_CACHE_HOURS = 168
_SCRYFALL_COLLECTION_URL = "https://api.scryfall.com/cards/collection"
def _get_precon_dir() -> Path:
"""Return the active precon-decks directory from CFG, with fallback."""
from models.state import CFG
if CFG.precon_dir:
return Path(CFG.precon_dir)
return _DEFAULT_PRECON_DIR
# Re-export for backwards compat — callers that import PRECON_DIR at module
# level will get the default. Runtime code should call _get_precon_dir().
PRECON_DIR = _DEFAULT_PRECON_DIR
def load_precon_index():
"""Load precon index from disk into the shared PRECON_INDEX list.
We mutate the list in-place (clear + extend) so that any module that
captured a reference via ``from precon_service import PRECON_INDEX``
will see the updated contents.
"""
precon_dir = _get_precon_dir()
idx_path = precon_dir / "precon-index.json"
if idx_path.exists():
with open(idx_path, "r", encoding="utf-8") as f:
data = json.load(f)
PRECON_INDEX.clear()
PRECON_INDEX.extend(data)
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
else:
PRECON_INDEX.clear()
log.info(f" Precons: index not found at {idx_path}")
def _sanitize_filename(name: str) -> str:
safe = re.sub(r'[<>:"/\\|?*]', '', name)
safe = safe.replace(' ', '_').replace("'", '').replace('!', '')
return re.sub(r'_+', '_', safe).strip('_')
def _deck_to_dck(deck_data: dict) -> str:
lines = ["[metadata]", f"Name={deck_data['name']}"]
commanders = deck_data.get('commander', [])
if commanders:
lines.append("[Commander]")
for card in commanders:
lines.append(f"{card.get('count', 1)} {card['name']}")
cards = deck_data.get('cards', [])
if cards:
lines.append("[Main]")
for card in cards:
lines.append(f"{card.get('count', 1)} {card['name']}")
sideboard = deck_data.get('sideboard', [])
if sideboard:
lines.append("[Sideboard]")
for card in sideboard:
lines.append(f"{card.get('count', 1)} {card['name']}")
return "\n".join(lines) + "\n"
def download_precon_database(force: bool = False) -> dict:
"""Download precon database from GitHub and populate PRECON_INDEX.
We mutate the list in-place (clear + extend) so that any module that
captured a reference via ``from precon_service import PRECON_INDEX``
will see the updated contents.
"""
precon_dir = _get_precon_dir()
idx_path = precon_dir / "precon-index.json"
if not force and idx_path.exists():
try:
with open(idx_path, "r", encoding="utf-8") as f:
existing = json.load(f)
if len(existing) > 50:
# Invalidate cache if missing enrichment fields (imageUrl, colors)
has_enrichment = existing[0].get("imageUrl") or existing[0].get("colors")
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
if has_enrichment and age_hours < PRECON_CACHE_HOURS:
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
PRECON_INDEX.clear()
PRECON_INDEX.extend(existing)
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
if not has_enrichment:
log.info(" Precons: Cache missing enrichment data, re-downloading...")
except Exception:
pass
log.info(" Precons: Downloading full precon database from GitHub...")
try:
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
with urlopen(req, timeout=120) as resp:
all_decks = json.loads(resp.read())
except Exception as e:
msg = f"Failed to download precon database: {e}"
log.error(f" Precons: ERROR - {msg}")
if idx_path.exists():
load_precon_index()
return {"downloaded": 0, "skipped": False, "error": msg}
commander_decks = [
d for d in all_decks
if d.get('type') == 'Commander Deck'
and (d.get('format') or '').lower() == 'commander'
]
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
precon_dir.mkdir(parents=True, exist_ok=True)
name_counts = Counter(_sanitize_filename(d['name']) for d in commander_decks)
dup_names = {n for n, c in name_counts.items() if c > 1}
index = []
written = 0
for deck in sorted(commander_decks, key=lambda d: (d.get('release_date', ''), d.get('name', ''))):
safe_name = _sanitize_filename(deck['name'])
if safe_name in dup_names:
sc = (deck.get('set_code') or 'unk').upper()
safe_name = f"{safe_name}_{sc}"
file_name = f"{safe_name}.dck"
dck_path = precon_dir / file_name
commanders = deck.get('commander', [])
cmdr_names = [c['name'] for c in commanders] if commanders else []
# Build Scryfall art_crop image URL from first commander's set/number
image_url = ""
if commanders:
c0 = commanders[0]
sc = (c0.get('set_code') or '').lower()
num = c0.get('number', '')
if sc and num:
image_url = f"https://api.scryfall.com/cards/{sc}/{num}?format=image&version=art_crop"
total_cards = sum(c.get('count', 1) for c in deck.get('cards', [])) + sum(c.get('count', 1) for c in commanders)
with open(dck_path, "w", encoding="utf-8") as f:
f.write(_deck_to_dck(deck))
written += 1
release = deck.get('release_date', '')
year = int(release[:4]) if release and len(release) >= 4 else 0
index.append({
"name": deck['name'], "commander": cmdr_names[0] if cmdr_names else "Unknown",
"commanders": cmdr_names, "colors": [],
"set": deck.get('set_name', ''), "setCode": deck.get('set_code', ''),
"year": year, "releaseDate": release, "theme": "",
"fileName": file_name, "cardCount": total_cards,
"imageUrl": image_url,
})
# Enrich with color identity from Scryfall (best-effort)
_enrich_colors(index)
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2, ensure_ascii=False)
PRECON_INDEX.clear()
PRECON_INDEX.extend(index)
log.info(f" Precons: {written} .dck files written, index saved")
return {"downloaded": written, "skipped": False, "total": written, "error": None}
def _enrich_colors(index: list[dict]) -> None:
"""Batch-fetch color_identity for commanders via Scryfall /cards/collection.
Scryfall accepts up to 75 identifiers per request. We look up each unique
commander name once, then propagate the result to every index entry.
"""
# Build unique commander -> identifiers map
cmdr_lookup: dict[str, dict] = {} # name -> {"name": ...}
for entry in index:
name = entry.get("commander", "")
if name and name != "Unknown" and name not in cmdr_lookup:
cmdr_lookup[name] = {"name": name}
if not cmdr_lookup:
return
names = list(cmdr_lookup.keys())
color_map: dict[str, list[str]] = {} # commander name -> ["W","U",...]
# Batch in chunks of 75
for i in range(0, len(names), 75):
batch = [{"name": n} for n in names[i:i + 75]]
try:
payload = json.dumps({"identifiers": batch}).encode("utf-8")
req = Request(
_SCRYFALL_COLLECTION_URL,
data=payload,
headers={
"Content-Type": "application/json",
"User-Agent": "CommanderAILab/3.0",
},
method="POST",
)
with urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
for card in result.get("data", []):
cname = card.get("name", "")
ci = card.get("color_identity", [])
if cname and ci:
color_map[cname] = ci
# Handle double-faced names: "A // B" -> also store "A"
if " // " in cname:
front = cname.split(" // ")[0]
color_map[front] = ci
# Respect Scryfall rate limit (100ms between requests)
time.sleep(0.15)
except Exception as e:
log.warning(f" Precons: Scryfall color lookup failed for batch {i}: {e}")
# Apply colors to index entries
enriched = 0
for entry in index:
name = entry.get("commander", "")
if name in color_map:
entry["colors"] = color_map[name]
enriched += 1
log.info(f" Precons: Enriched {enriched}/{len(index)} decks with color identity")
+216
View File
@@ -0,0 +1,216 @@
"""Scryfall API client and cache service."""
import json
import os
import sqlite3
import threading
import time
import datetime
from pathlib import Path
from typing import Optional
from urllib.request import Request, urlopen
# Lazy import to avoid circular dependency
def _detect_card_roles(oracle_text, type_line, keywords):
from services.card_analysis import _detect_card_roles as _dcr
return _dcr(oracle_text, type_line, keywords)
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent.parent / "scryfall_cache.db"
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600))
_API_HEADERS = {"User-Agent": "CommanderAILab/3.0", "Accept": "application/json"}
class _ScryfallCache:
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
self._lock = threading.Lock()
self._conn: Optional[sqlite3.Connection] = None
self._hits = 0
self._misses = 0
self._init_db()
def _init_db(self):
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("""
CREATE TABLE IF NOT EXISTS scryfall_cache (
cache_key TEXT PRIMARY KEY,
json_blob TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
self._conn.commit()
@staticmethod
def _make_key(name: str, set_code: str, collector_number: str) -> str:
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
key = self._make_key(name, set_code, collector_number)
with self._lock:
row = self._conn.execute(
"SELECT json_blob FROM scryfall_cache WHERE cache_key = ?", (key,)
).fetchone()
if row:
self._hits += 1
return json.loads(row[0])
self._misses += 1
return None
def put(self, name: str, set_code: str, collector_number: str, data: dict):
key = self._make_key(name, set_code, collector_number)
blob = json.dumps(data)
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob) VALUES (?, ?)",
(key, blob),
)
self._conn.commit()
def stats(self) -> dict:
with self._lock:
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
return {"cached": count, "hits": self._hits, "misses": self._misses}
def clear(self) -> int:
with self._lock:
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
self._conn.execute("DELETE FROM scryfall_cache")
self._conn.commit()
self._conn.execute("VACUUM")
self._hits = 0; self._misses = 0
return count
def evict_expired(self) -> int:
cutoff = datetime.datetime.utcnow().isoformat()
with self._lock:
self._conn.execute(
"DELETE FROM scryfall_cache WHERE (julianday(?) - julianday(fetched_at)) * 86400 > ?",
(cutoff, self._ttl),
)
deleted = self._conn.total_changes
self._conn.commit()
return deleted
_scryfall_cache = _ScryfallCache()
_scryfall_lock = threading.Lock()
_scryfall_last_call = 0.0
def _scryfall_rate_limit():
global _scryfall_last_call
with _scryfall_lock:
now = time.monotonic()
elapsed = now - _scryfall_last_call
if elapsed < 0.1:
time.sleep(0.1 - elapsed)
_scryfall_last_call = time.monotonic()
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
from urllib.parse import quote
cached = _scryfall_cache.get(name, set_code, collector_number)
if cached is not None:
return cached
card_data = None
last_error = ""
if set_code and collector_number:
_scryfall_rate_limit()
try:
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e); card_data = None
if not card_data:
_scryfall_rate_limit()
try:
url = f"https://api.scryfall.com/cards/named?exact={quote(name)}"
with urlopen(Request(url, headers=_API_HEADERS), timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
except Exception as e:
last_error = str(e)
if not card_data or card_data.get("object") == "error":
err_detail = card_data.get("details", "unknown error") if card_data else last_error
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
_scryfall_cache.put(name, set_code, collector_number, card_data)
resolved_name = card_data.get("name", "")
if resolved_name and resolved_name.lower() != name.strip().lower():
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
return card_data
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
card_data = _fetch_scryfall_api(name, set_code, collector_number)
if not card_data or "_error" in card_data:
return card_data or {}
type_line = card_data.get("type_line", "")
color_identity = card_data.get("color_identity", [])
keywords = card_data.get("keywords", [])
subtypes = []
for sep in [" \u2014 ", " - "]:
if sep in type_line:
subtypes = [s.strip() for s in type_line.split(sep, 1)[1].split() if s.strip()]
break
is_legendary = 1 if "Legendary" in type_line else 0
is_basic = 1 if "Basic" in type_line else 0
prices = card_data.get("prices", {})
try:
tcg_price = float(prices.get("usd") or prices.get("usd_foil") or "0") or 0.0
except (ValueError, TypeError):
tcg_price = 0.0
oracle_text = card_data.get("oracle_text", "")
if not oracle_text and card_data.get("card_faces"):
oracle_text = "\n//\n".join(f.get("oracle_text", "") for f in card_data["card_faces"] if f.get("oracle_text"))
power = card_data.get("power", "")
toughness = card_data.get("toughness", "")
if not power and card_data.get("card_faces"):
power = card_data["card_faces"][0].get("power", "")
toughness = card_data["card_faces"][0].get("toughness", "")
mana_cost = card_data.get("mana_cost", "")
if not mana_cost and card_data.get("card_faces"):
mana_cost = card_data["card_faces"][0].get("mana_cost", "")
auto_roles = _detect_card_roles(oracle_text, type_line, keywords)
return {
"name": card_data.get("name", name),
"type_line": type_line,
"subtypes": json.dumps(subtypes),
"is_legendary": is_legendary,
"is_basic": is_basic,
"color_identity": json.dumps(color_identity),
"cmc": card_data.get("cmc", 0.0),
"mana_cost": mana_cost,
"oracle_text": oracle_text,
"keywords": json.dumps(keywords),
"power": power or "",
"toughness": toughness or "",
"rarity": card_data.get("rarity", ""),
"set_name": card_data.get("set_name", ""),
"edhrec_rank": card_data.get("edhrec_rank", 0) or 0,
"tcg_price": tcg_price,
"salt_score": 0.0,
"is_game_changer": 0,
"category": json.dumps(auto_roles),
"scryfall_id": card_data.get("id", ""),
"tcgplayer_id": str(card_data.get("tcgplayer_id", "")),
}
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
"""Fuzzy-search Scryfall for a card name. Returns raw Scryfall JSON dict, or None on failure."""
from urllib.parse import quote
_scryfall_rate_limit()
try:
encoded = quote(name)
url = f"https://api.scryfall.com/cards/named?fuzzy={encoded}"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
if data.get("object") == "error":
return None
return data
except Exception:
return None
@@ -0,0 +1,18 @@
"""
Commander AI Deck Builder
~~~~~~~~~~~~~~~~~~~~~~~~~
Top-level package for the deck builder module.
Usage::
from commander_ai_lab.deck_builder import build_deck, BuildRequest
result = build_deck(BuildRequest(commander_name="Atraxa, Praetors' Voice"))
"""
__version__ = "0.1.0"
from .pipeline import build_deck
from .core.models import BuildRequest, BuildResult
__all__ = ["build_deck", "BuildRequest", "BuildResult", "__version__"]
@@ -0,0 +1,238 @@
"""
Adapter: bridges the new Ollama deck_builder pipeline to the legacy
DeckGeneratorV3 dict interface expected by lab_api.py endpoints.
"""
from __future__ import annotations
import logging
import sqlite3
from collections import Counter
from types import SimpleNamespace
from typing import Any, Callable, Dict, List, Optional
from .pipeline.build_deck import build_deck
from .core.models import BuildRequest, BuildResult, DeckRatios
logger = logging.getLogger("commander_ai_lab.deckgen")
class DeckBuilderAdapter:
"""
Drop-in replacement for DeckGeneratorV3.
Wraps the Ollama-based build_deck pipeline and returns
the same dict shape the existing FastAPI endpoints expect.
"""
def __init__(
self,
db_conn_factory: Callable[[], sqlite3.Connection] = None,
model: str = "gpt-oss:20b",
):
self.db_conn_factory = db_conn_factory
self.model = model
# -- public API (matches DeckGeneratorV3.generate_deck signature) --
def generate_deck(
self,
commander_name: str,
strategy: str = "",
target_bracket: int = 3,
budget_usd: Optional[float] = None,
budget_mode: str = "total",
omit_cards: Optional[List[str]] = None,
use_collection: bool = True,
model: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Run the 7-step Ollama pipeline and return a dict that matches
the shape expected by /api/deck/v3/generate.
"""
request = BuildRequest(
commander_name=commander_name,
strategy_notes=strategy or None,
collection_only=use_collection,
budget_limit=budget_usd,
ratios=DeckRatios(),
)
result: BuildResult = build_deck(request)
deck = result.deck
commander = deck.commander
color_identity = sorted(commander.color_identity)
cards_with_status = self._check_ownership(
[self._card_to_dict(c) for c in deck.cards]
)
cards_with_status = self._fill_basic_lands(
cards_with_status, color_identity, target_total=100
)
stats = self._compute_stats(cards_with_status)
raw_deck = {
"commander": commander.name,
"strategy_summary": strategy or f"{commander.name} synergy deck",
"archetype": strategy or "synergy",
"bracket": {"level": target_bracket},
"reasoning": {
"strategy": f"Built with local Ollama ({self.model})",
"sources": result.sources_consulted,
"warnings": result.warnings,
},
"estimated_total_usd": 0,
"cards": [self._card_to_raw(c) for c in deck.cards],
}
return {
"commander": {
"name": commander.name,
"type_line": commander.type_line or "Legendary Creature",
"color_identity": color_identity,
"mana_cost": commander.mana_cost or "",
"cmc": commander.cmc,
"scryfall_id": commander.scryfall_id,
},
"color_identity": color_identity,
"raw_deck": raw_deck,
"cards": cards_with_status,
"stats": stats,
"strategy_summary": raw_deck["strategy_summary"],
"bracket": raw_deck["bracket"],
"archetype": raw_deck["archetype"],
"reasoning": raw_deck["reasoning"],
"estimated_total_usd": 0,
"tokens_used": {"prompt": 0, "completion": 0},
"model": self.model,
"citations": [],
"truncated": False,
"build_time_seconds": result.build_time_seconds,
}
# -- substitution stub --
def run_substitution(self, cards, commander: str = "", strategy: str = "", **kw):
"""
No-op substitution -- Ollama pipeline handles collection internally.
Returns a SimpleNamespace matching the shape DeckGeneratorV3 returns.
"""
owned = sum(
1 for c in cards
if getattr(c, "owned", False)
or (isinstance(c, dict) and c.get("owned"))
)
return SimpleNamespace(
cards=cards,
owned_count=owned,
substituted_count=0,
missing_count=len(cards) - owned,
)
# -- helpers --
@staticmethod
def _card_to_dict(card) -> Dict[str, Any]:
return {
"name": card.name,
"count": card.quantity,
"category": card.category,
"mana_cost": card.mana_cost or "",
"cmc": card.cmc,
"type_line": card.type_line or "",
"color_identity": sorted(card.color_identity),
"scryfall_id": card.scryfall_id,
"edhrec_rank": card.edhrec_rank,
"source": card.source or "ollama",
}
@staticmethod
def _card_to_raw(card) -> Dict[str, Any]:
return {
"name": card.name,
"count": card.quantity,
"category": card.category,
"reasoning": "",
}
def _check_ownership(self, cards: List[Dict]) -> List[Dict]:
if not self.db_conn_factory:
for c in cards:
c["owned"] = False
c["owned_qty"] = 0
c["from_collection"] = False
c.setdefault("estimated_price_usd", 0)
return cards
try:
conn = self.db_conn_factory()
for card in cards:
name = card.get("name", "")
if not name:
continue
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price "
"FROM collection_entries WHERE name = ? COLLATE NOCASE LIMIT 1",
(name,),
).fetchone()
if row:
card["owned"] = True
card["owned_qty"] = row[2] if row else 0
card["from_collection"] = True
card["scryfall_id"] = card.get("scryfall_id") or row[1]
card["estimated_price_usd"] = float(row[3]) if row[3] else 0
else:
card["owned"] = False
card["owned_qty"] = 0
card["from_collection"] = False
card.setdefault("estimated_price_usd", 0)
except Exception as e:
logger.warning("Collection check failed: %s", e)
for c in cards:
c.setdefault("owned", False)
c.setdefault("owned_qty", 0)
c.setdefault("from_collection", False)
c.setdefault("estimated_price_usd", 0)
return cards
@staticmethod
def _fill_basic_lands(
cards: List[Dict], color_identity: List[str], target_total: int = 100
) -> List[Dict]:
current = sum(c.get("count", 1) for c in cards)
needed = target_total - current
if needed <= 0:
return cards
ci_to_basic = {"W": "Plains", "U": "Island", "B": "Swamp", "R": "Mountain", "G": "Forest"}
basics = [ci_to_basic[c] for c in color_identity if c in ci_to_basic] or ["Wastes"]
for i in range(needed):
land_name = basics[i % len(basics)]
existing = next((c for c in cards if c["name"] == land_name), None)
if existing:
existing["count"] = existing.get("count", 1) + 1
else:
cards.append({
"name": land_name, "count": 1, "category": "lands",
"mana_cost": "", "cmc": 0, "type_line": "Basic Land",
"color_identity": [], "owned": False, "owned_qty": 0,
"from_collection": False, "estimated_price_usd": 0,
"source": "auto-fill",
})
return cards
@staticmethod
def _compute_stats(cards: List[Dict]) -> Dict[str, Any]:
total = sum(c.get("count", 1) for c in cards)
categories = Counter()
cmc_total = 0.0
nonland_count = 0
for c in cards:
count = c.get("count", 1)
cat = c.get("category", "uncategorized")
categories[cat] += count
if cat != "lands":
cmc_total += c.get("cmc", 0) * count
nonland_count += count
avg_cmc = round(cmc_total / nonland_count, 2) if nonland_count else 0
return {
"total_cards": total,
"category_breakdown": dict(categories),
"average_cmc": avg_cmc,
"land_count": categories.get("lands", 0),
"nonland_count": nonland_count,
}
@@ -0,0 +1,13 @@
"""API subpackage – external service clients (Scryfall, EDHRec, Ollama)."""
from . import scryfall
from . import edhrec
from . import ollama_client
from .community_scraper import CommunityScraper
__all__ = [
"scryfall",
"edhrec",
"ollama_client",
"CommunityScraper",
]
@@ -0,0 +1,136 @@
"""
Community scraper – pull popular decklists from Moxfield, Archidekt, and Reddit.
This is a Phase-3 expansion module. Currently provides stub implementations
that return empty results. Wire up real scraping logic as the project matures.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import requests
logger = logging.getLogger(__name__)
MOXFIELD_API = "https://api2.moxfield.com/v3/decks/search"
ARCHIDEKT_API = "https://archidekt.com/api/decks/"
@dataclass
class CommunityDeck:
"""Minimal representation of a community-sourced decklist."""
source: str # "moxfield" | "archidekt" | "reddit"
title: str
url: str
card_names: List[str] = field(default_factory=list)
author: Optional[str] = None
class CommunityScraper:
"""Aggregate community decklists for a given commander."""
def __init__(self, timeout: int = 15):
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update({"User-Agent": "CommanderAILab/0.1"})
# ------------------------------------------------------------------
# Moxfield
# ------------------------------------------------------------------
def search_moxfield(
self, commander_name: str, *, max_results: int = 10
) -> List[CommunityDeck]:
"""Search Moxfield for public decks featuring *commander_name*."""
try:
resp = self._session.get(
MOXFIELD_API,
params={
"q": commander_name,
"fmt": "commander",
"sort": "views",
"sortDirection": "descending",
"pageSize": max_results,
},
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
decks: List[CommunityDeck] = []
for item in data.get("data", []):
pub_id = item.get("publicId", "")
decks.append(
CommunityDeck(
source="moxfield",
title=item.get("name", ""),
url=f"https://www.moxfield.com/decks/{pub_id}",
author=item.get("createdByUser", {}).get("userName"),
)
)
logger.info("Moxfield returned %d decks for %s", len(decks), commander_name)
return decks
except Exception:
logger.warning("Moxfield search failed for %s", commander_name, exc_info=True)
return []
# ------------------------------------------------------------------
# Archidekt
# ------------------------------------------------------------------
def search_archidekt(
self, commander_name: str, *, max_results: int = 10
) -> List[CommunityDeck]:
"""Search Archidekt for public decks featuring *commander_name*."""
try:
resp = self._session.get(
ARCHIDEKT_API,
params={
"commanders": commander_name,
"formats": 3, # Commander
"orderBy": "-viewCount",
"pageSize": max_results,
},
timeout=self.timeout,
)
resp.raise_for_status()
data = resp.json()
decks: List[CommunityDeck] = []
for item in data.get("results", []):
deck_id = item.get("id", "")
decks.append(
CommunityDeck(
source="archidekt",
title=item.get("name", ""),
url=f"https://archidekt.com/decks/{deck_id}",
author=item.get("owner", {}).get("username"),
)
)
logger.info("Archidekt returned %d decks for %s", len(decks), commander_name)
return decks
except Exception:
logger.warning("Archidekt search failed for %s", commander_name, exc_info=True)
return []
# ------------------------------------------------------------------
# Aggregate
# ------------------------------------------------------------------
def search_all(
self, commander_name: str, *, max_per_source: int = 10
) -> List[CommunityDeck]:
"""Search all community sources and return combined results."""
results: List[CommunityDeck] = []
results.extend(self.search_moxfield(commander_name, max_results=max_per_source))
results.extend(self.search_archidekt(commander_name, max_results=max_per_source))
return results
def extract_popular_cards(
self, decks: List[CommunityDeck], *, min_frequency: int = 2
) -> Dict[str, int]:
"""Count card frequency across decks; return cards seen >= min_frequency."""
counts: Dict[str, int] = {}
for deck in decks:
for name in deck.card_names:
counts[name] = counts.get(name, 0) + 1
return {k: v for k, v in counts.items() if v >= min_frequency}
@@ -0,0 +1,180 @@
"""
EDHrec JSON client for the Commander AI Deck Builder.
Fetches commander recommendations, top cards, and synergy data
from EDHrec's public JSON API (json.edhrec.com).
"""
from __future__ import annotations
import logging
import re
from typing import Dict, List, Optional
import requests
from ..core.models import CardEntry
logger = logging.getLogger(__name__)
BASE_URL = "https://json.edhrec.com/pages"
HEADERS = {
"User-Agent": "CommanderAI-DeckBuilder/0.1 (github.com/KoalaTrapLord/commander-ai-lab)",
}
def _slugify(name: str) -> str:
"""Convert a commander name to an EDHrec URL slug."""
slug = name.lower().strip()
slug = re.sub(r"[',.]", "", slug)
slug = re.sub(r"\s+", "-", slug)
return slug
def _get_json(path: str) -> Optional[Dict]:
"""Fetch JSON from EDHrec, returning None on failure."""
url = f"{BASE_URL}/{path}.json"
try:
resp = requests.get(url, headers=HEADERS, timeout=15)
resp.raise_for_status()
return resp.json()
except (requests.HTTPError, requests.ConnectionError, ValueError) as e:
logger.warning(f"EDHrec fetch failed for {url}: {e}")
return None
# ── commander page ───────────────────────────────────────────────
def get_commander_data(commander_name: str) -> Optional[Dict]:
"""
Fetch the full EDHrec commander page JSON.
Returns the raw dict with keys like 'cardlists', 'container', etc.
"""
slug = _slugify(commander_name)
return _get_json(f"commanders/{slug}")
def get_recommended_cards(commander_name: str) -> List[CardEntry]:
"""
Get EDHrec's recommended cards for a commander.
Returns CardEntry objects with edhrec_rank populated from
the 'num_decks' / inclusion percentage.
"""
data = get_commander_data(commander_name)
if not data:
return []
cards: List[CardEntry] = []
cardlists = data.get("container", {}).get("json_dict", {}).get("cardlists", [])
for cardlist in cardlists:
header = cardlist.get("header", "uncategorized").lower()
category = _map_edhrec_category(header)
for card_data in cardlist.get("cardviews", []):
name = card_data.get("name", "")
if not name:
continue
cards.append(
CardEntry(
name=name,
category=category,
color_identity=set(card_data.get("color_identity", [])),
cmc=card_data.get("cmc", 0.0),
type_line=card_data.get("type_line"),
edhrec_rank=card_data.get("num_decks"),
source="edhrec",
)
)
return cards
# ── top cards by color identity ──────────────────────────────────
def get_top_cards_by_colors(colors: str) -> List[CardEntry]:
"""
Fetch top EDHrec cards for a color combination.
Args:
colors: e.g. "wub", "rg", "wubrg", "colorless"
"""
data = _get_json(f"commanders/{colors.lower()}")
if not data:
return []
cards: List[CardEntry] = []
card_list = data.get("container", {}).get("json_dict", {}).get("cardlists", [])
for cardlist in card_list:
for card_data in cardlist.get("cardviews", []):
name = card_data.get("name", "")
if name:
cards.append(
CardEntry(
name=name,
color_identity=set(card_data.get("color_identity", [])),
edhrec_rank=card_data.get("num_decks"),
source="edhrec",
)
)
return cards
# ── average deck ─────────────────────────────────────────────────
def get_average_deck(commander_name: str) -> List[CardEntry]:
"""
Fetch the EDHrec 'average deck' for a commander.
This is the most common 99 cards people play with this commander.
"""
slug = _slugify(commander_name)
data = _get_json(f"average-decks/{slug}")
if not data:
return []
cards: List[CardEntry] = []
deck_data = data.get("container", {}).get("json_dict", {})
for card_data in deck_data.get("cardlists", [{}]):
for cv in card_data.get("cardviews", []):
name = cv.get("name", "")
if name:
cards.append(
CardEntry(
name=name,
color_identity=set(cv.get("color_identity", [])),
edhrec_rank=cv.get("num_decks"),
source="edhrec",
)
)
return cards
# ── category mapper ──────────────────────────────────────────────
def _map_edhrec_category(header: str) -> str:
"""Map EDHrec section headers to our deck categories."""
header = header.lower()
mapping = {
"ramp": "ramp",
"mana": "ramp",
"land": "lands",
"lands": "lands",
"draw": "card_draw",
"card draw": "card_draw",
"card advantage": "card_draw",
"removal": "removal",
"interaction": "removal",
"board wipe": "removal",
"wipe": "removal",
"protection": "protection",
"counter": "protection",
"win": "wincon",
"finisher": "wincon",
"combo": "wincon",
}
for keyword, category in mapping.items():
if keyword in header:
return category
return "synergy"
@@ -0,0 +1,326 @@
"""
Ollama gpt-oss:20b client for the Commander AI Deck Builder.
Uses the OpenAI-compatible API exposed by Ollama at localhost:11434.
Provides 6 core functions:
1. analyze_synergies - Rank card candidates by synergy with commander
2. suggest_cards - Suggest cards for a specific category
3. filter_color_identity - Validate cards against commander colors
4. enforce_deck_ratios - Adjust card counts to hit ratio targets
5. assemble_deck_json - Produce final structured 99-card JSON
6. chat - Raw chat completion for ad-hoc queries
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from openai import OpenAI
logger = logging.getLogger(__name__)
# Ollama exposes an OpenAI-compatible endpoint
DEFAULT_BASE_URL = "http://localhost:11434/v1"
DEFAULT_MODEL = "gpt-oss:20b"
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
"""Create an OpenAI client pointing at the local Ollama server."""
return OpenAI(base_url=base_url, api_key="ollama")
def _extract_json(raw: str) -> Any:
"""Best-effort extraction of JSON from possibly messy LLM output.
Handles:
- Markdown code fences (```json ... ```)
- Leading/trailing prose around JSON
- Trailing commas before } or ]
"""
text = raw.strip()
# Strip markdown fences
if "```" in text:
match = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
if match:
text = match.group(1).strip()
# Try direct parse first
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Remove trailing commas (common LLM error)
cleaned = re.sub(r",\s*([}\]])", r"\1", text)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
# Try to find the first { or [ and last } or ]
for start_char, end_char in [("{", "}"), ("[", "]")]:
start = text.find(start_char)
end = text.rfind(end_char)
if start != -1 and end != -1 and end > start:
substring = text[start : end + 1]
substring = re.sub(r",\s*([}\]])", r"\1", substring)
try:
return json.loads(substring)
except json.JSONDecodeError:
continue
raise json.JSONDecodeError("No valid JSON found in response", text, 0)
def chat(
messages: List[Dict[str, str]],
model: str = DEFAULT_MODEL,
temperature: float = 0.7,
max_tokens: int = 4096,
json_mode: bool = False,
) -> str:
"""
Send a chat completion request to Ollama.
Returns the assistant's response as a string.
"""
client = _get_client()
kwargs: Dict[str, Any] = {
"model": model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**kwargs)
return response.choices[0].message.content or ""
# ── 1. Analyze synergies ─────────────────────────────────────────
def analyze_synergies(
commander_name: str,
commander_text: str,
candidates: List[str],
strategy_notes: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Rank candidate cards by synergy with the commander.
Returns a list of dicts: [{"name": str, "score": int, "reason": str}, ...]
Score is 1-10.
"""
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Commander text: {commander_text}
{strategy}
Rate each of these cards on a 1-10 synergy scale with this commander.
Return ONLY a JSON array of objects with keys: "name", "score", "reason".
Cards to evaluate:
{json.dumps(candidates)}"""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Always respond with valid JSON only. No explanation text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True)
try:
result = _extract_json(raw)
if isinstance(result, dict) and "cards" in result:
return result["cards"]
if isinstance(result, list):
return result
return []
except json.JSONDecodeError:
logger.error("Failed to parse synergy response: %s", raw[:500])
return []
# ── 2. Suggest cards for a category ──────────────────────────────
def suggest_cards(
commander_name: str,
color_identity: List[str],
category: str,
count: int = 10,
exclude: Optional[List[str]] = None,
strategy_notes: Optional[str] = None,
) -> List[str]:
"""
Ask the model to suggest cards for a specific deck category.
Returns a list of card names.
"""
exclude_str = f"\nDo NOT suggest these cards: {json.dumps(exclude)}" if exclude else ""
strategy = f"\nStrategy notes: {strategy_notes}" if strategy_notes else ""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Color identity: {', '.join(color_identity)}
Category: {category}
{strategy}
{exclude_str}
Suggest exactly {count} cards for the {category} category that work well with this commander.
Only suggest cards that are legal in Commander and match the color identity.
Return ONLY a JSON array of card name strings, like: ["Card A", "Card B", ...]"""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON array of strings. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True)
logger.debug("suggest_cards raw response: %s", raw[:500])
try:
result = _extract_json(raw)
if isinstance(result, dict):
# Handle {"cards": [...]} or {"suggestions": [...]} etc.
for key in ("cards", "suggestions", "card_names", "names"):
if key in result and isinstance(result[key], list):
return [str(c) for c in result[key]]
# If dict has a single list value, use it
for v in result.values():
if isinstance(v, list):
return [str(c) for c in v]
if isinstance(result, list):
return [str(c) for c in result]
return []
except json.JSONDecodeError:
logger.error("Failed to parse suggestion response: %s", raw[:500])
return []
# ── 3. Filter by color identity ──────────────────────────────────
def filter_color_identity(
card_names: List[str],
commander_colors: List[str],
) -> List[str]:
"""
Ask the model to filter out cards that violate color identity.
Returns only the cards that are legal for the commander.
"""
prompt = f"""You are a Magic: The Gathering rules expert.
Commander color identity: {', '.join(commander_colors)}
From this list, return ONLY the cards whose color identity is a subset of the commander's.
Remove any card that has mana symbols or color identity outside these colors.
Return a JSON array of valid card name strings.
Cards:
{json.dumps(card_names)}"""
messages = [
{"role": "system", "content": "You are an MTG rules expert. Respond with ONLY a JSON array. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True)
try:
result = _extract_json(raw)
if isinstance(result, dict):
for key in ("cards", "valid_cards", "legal_cards"):
if key in result and isinstance(result[key], list):
return [str(c) for c in result[key]]
for v in result.values():
if isinstance(v, list):
return [str(c) for c in v]
if isinstance(result, list):
return [str(c) for c in result]
return card_names # fallback: return all
except json.JSONDecodeError:
return card_names
# ── 4. Enforce deck ratios ───────────────────────────────────────
def enforce_deck_ratios(
cards_by_category: Dict[str, List[str]],
target_ratios: Dict[str, int],
commander_name: str,
) -> Dict[str, List[str]]:
"""
Ask the model to trim/expand categories to hit target counts.
Returns adjusted dict of category -> card names.
"""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Current cards by category:
{json.dumps(cards_by_category, indent=2)}
Target card counts per category:
{json.dumps(target_ratios, indent=2)}
Adjust each category to match the target count. Remove the weakest cards from
over-filled categories. For under-filled categories, suggest additional cards.
The total across all categories must be exactly 99.
Return a JSON object with the same category keys, each mapping to an array of card name strings."""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON object. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True, max_tokens=8192)
try:
result = _extract_json(raw)
if isinstance(result, dict):
return {k: [str(c) for c in v] for k, v in result.items() if isinstance(v, list)}
return cards_by_category
except json.JSONDecodeError:
logger.error("Failed to parse ratio response: %s", raw[:500])
return cards_by_category
# ── 5. Assemble final deck JSON ──────────────────────────────────
def assemble_deck_json(
commander_name: str,
cards_by_category: Dict[str, List[str]],
) -> Dict[str, Any]:
"""
Ask the model to produce the final structured deck JSON.
Returns a dict matching the CommanderDeck schema.
"""
prompt = f"""You are a Magic: The Gathering Commander deck-building expert.
Commander: {commander_name}
Final card selections by category:
{json.dumps(cards_by_category, indent=2)}
Produce the final deck as a JSON object with this structure:
{{
"commander": {{"name": "...", "category": "commander"}},
"cards": [
{{"name": "...", "category": "...", "quantity": 1}},
...
]
}}
The "cards" array must have exactly 99 entries (one per card, quantity 1 each).
Use the category from the input grouping for each card.
Return ONLY the JSON object."""
messages = [
{"role": "system", "content": "You are an expert MTG Commander deck builder. Respond with ONLY a JSON object. No other text."},
{"role": "user", "content": prompt},
]
raw = chat(messages, json_mode=True, max_tokens=16384)
try:
return _extract_json(raw)
except json.JSONDecodeError:
logger.error("Failed to parse deck JSON: %s", raw[:500])
return {}
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more