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
144 changed files with 18130 additions and 14687 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
+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
+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
@@ -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
}
+32 -1
View File
@@ -1,15 +1,46 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
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>
);
+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>
);
}
+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;
}
+163 -22
View File
@@ -8,6 +8,12 @@ import logging
import os
from pathlib import Path
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # dotenv is optional; env vars can be set directly
try:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -38,11 +44,16 @@ from routes.ml import router as ml_router
log = logging.getLogger("commander_ai_lab.api")
# ── CORS origins (env-configurable) ────────────────────────────
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080"
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
# ── App ────────────────────────────────────────────────────────
app = FastAPI(title="Commander AI Lab API", version="3.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=allowed_origins,
allow_methods=["*"],
allow_headers=["*"],
)
@@ -60,17 +71,25 @@ app.include_router(coach_router)
app.include_router(ml_router)
@app.get("/api/health")
async def health_check():
"""Health check endpoint for load balancers and Unity client polling."""
return {"status": "ok"}
@app.on_event("startup")
async def _on_startup():
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
import routes.shared as _shared
# Ensure precon_dir is resolved even when started via uvicorn directly
if not CFG.precon_dir:
CFG.precon_dir = _resolve_precon_dir()
_shared.init_collection_db()
if not _shared.PRECON_INDEX:
_shared.download_precon_database()
if not _shared.COMMANDER_META:
_shared.load_commander_meta()
# ── Static UI ──────────────────────────────────────────────────
_legacy_ui_dir = Path(__file__).parent / "ui"
_spa_dir = Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
@@ -81,7 +100,6 @@ elif _spa_dir.exists():
_spa_assets = _spa_dir / "assets"
if _spa_assets.exists():
app.mount("/assets", StaticFiles(directory=str(_spa_assets)), name="spa-assets")
@app.get("/{full_path:path}")
async def _spa_catchall(full_path: str):
from fastapi.responses import FileResponse
@@ -90,21 +108,20 @@ elif _spa_dir.exists():
return FileResponse(str(requested_file))
return FileResponse(str(_spa_dir / "index.html"))
# ── Startup ────────────────────────────────────────────────────
def _parse_args():
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
p.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", ""))
p.add_argument("--verbose", "-v", action="store_true")
return p.parse_args()
def _resolve_forge_decks_dir() -> str:
import sys
if sys.platform == "win32":
@@ -122,6 +139,97 @@ def _resolve_forge_decks_dir() -> str:
return str(candidate)
return ""
def _resolve_forge_dir() -> str:
"""Auto-detect the Forge installation directory.
The Forge dir is the root of a Forge install — it typically contains
a 'res' subdirectory with card data. We derive it from the JAR
location (parent of 'target/') or look in common install paths.
"""
import sys
# 1. If forge_jar is already resolved, derive forge_dir from it.
# JAR lives in <forge-root>/forge-gui-desktop/target/<jar>
# so forge_dir = JAR's grandparent's parent.
if CFG.forge_jar and os.path.isfile(CFG.forge_jar):
jar_path = Path(CFG.forge_jar)
# target/ -> forge-gui-desktop/ -> <forge-root>
candidate = jar_path.parent.parent.parent
if candidate.is_dir() and (candidate / "res").is_dir():
return str(candidate)
# Also try just one level up from target/
candidate = jar_path.parent.parent
# Check forge-gui sibling of forge-gui-desktop (res/ lives there)
forge_gui_sibling = jar_path.parent.parent.parent / "forge-gui"
if forge_gui_sibling.is_dir() and (forge_gui_sibling / "res").is_dir():
return str(forge_gui_sibling)
if candidate.is_dir() and (candidate / "res").is_dir():
return str(candidate)
# Fall back to the directory containing the JAR itself
candidate = jar_path.parent
if candidate.is_dir():
return str(candidate)
# 2. Check common sibling/child directories relative to project root
project_root = Path(__file__).parent
for candidate in [
project_root / "forge",
project_root.parent / "forge-repo",
project_root.parent / "forge-repo" / "forge-gui",
project_root.parent / "forge",
project_root / "forge-gui-desktop",
]:
if candidate.is_dir():
# Prefer directory that has 'res' subfolder
if (candidate / "res").is_dir():
return str(candidate)
# 3. Platform-specific user data directories
if sys.platform == "win32":
appdata = os.environ.get("APPDATA", "")
if appdata:
candidate = Path(appdata) / "Forge"
if candidate.is_dir():
return str(candidate)
home = Path.home()
for candidate in [
home / ".forge",
home / "Forge",
]:
if candidate.is_dir():
return str(candidate)
return ""
def _resolve_forge_jar() -> str:
"""Auto-detect the Forge GUI desktop JAR in common locations."""
project_root = Path(__file__).parent
# Candidate directories where the Forge JAR might live
candidates = [
project_root / "forge-gui-desktop" / "target",
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
project_root.parent / "forge" / "forge-gui-desktop" / "target",
]
# Also check FORGE_DIR/../forge-gui-desktop/target if FORGE_DIR is set
forge_dir_env = os.environ.get("FORGE_DIR", "")
if forge_dir_env:
fd = Path(forge_dir_env)
candidates.append(fd.parent / "forge-gui-desktop" / "target")
candidates.append(fd / "target")
for target_dir in candidates:
if target_dir.is_dir():
for pattern in [
"forge-gui-desktop-*-jar-with-dependencies.jar",
"forge-gui-desktop-*-shaded.jar",
"forge-gui-desktop-*.jar",
]:
jars = sorted(target_dir.glob(pattern))
jars = [j for j in jars if not j.name.startswith("original-")]
if jars:
return str(jars[0])
return ""
def _resolve_lab_jar() -> str:
target_dir = Path(__file__).parent / "target"
@@ -137,29 +245,63 @@ def _resolve_lab_jar() -> str:
return str(jars[0])
return ""
def _resolve_precon_dir() -> str:
"""Auto-detect the precon-decks directory.
The precon-decks dir lives in the project root and contains downloaded
.dck files plus precon-index.json. We check relative to this file
(lab_api.py) first, then common sibling paths.
"""
project_root = Path(__file__).parent
# 1. Standard location: <project-root>/precon-decks
candidate = project_root / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 2. Check parent directory (in case running from a subdirectory)
candidate = project_root.parent / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 3. Derive from forge_dir if available
if CFG.forge_dir:
candidate = Path(CFG.forge_dir) / "precon-decks"
if candidate.is_dir():
return str(candidate)
# 4. Fall back: create the directory in the project root so downloads work
fallback = project_root / "precon-decks"
fallback.mkdir(parents=True, exist_ok=True)
return str(fallback)
def main():
args = _parse_args()
setup_logging(logging.DEBUG if args.verbose else logging.INFO)
CFG.forge_jar = args.forge_jar
CFG.forge_dir = args.forge_dir
CFG.forge_jar = args.forge_jar or _resolve_forge_jar()
CFG.forge_dir = args.forge_dir or _resolve_forge_dir()
CFG.forge_decks_dir = args.forge_decks_dir or _resolve_forge_decks_dir()
CFG.lab_jar = args.lab_jar or _resolve_lab_jar()
CFG.port = args.port
CFG.lab_jar = args.lab_jar or _resolve_lab_jar()
CFG.precon_dir = args.precon_dir or _resolve_precon_dir()
CFG.port = args.port
CFG.ximilar_api_key = args.ximilar_key
CFG.pplx_api_key = args.pplx_key
CFG.pplx_api_key = args.pplx_key
from routes.shared import get_java17
j17 = get_java17()
log.info("Commander AI Lab — API Server v3.0.0")
log.info(f" Port: {CFG.port}")
log.info(f" Forge JAR: {CFG.forge_jar or 'NOT SET'}")
log.info(f" Decks Dir: {CFG.forge_decks_dir or 'NOT SET'}")
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
log.info(f" Java 17: {j17}")
log.info(f" Port: {CFG.port}")
log.info(f" Forge JAR: {CFG.forge_jar or 'NOT SET'}")
log.info(f" Forge Dir: {CFG.forge_dir or 'NOT SET'}")
log.info(f" Decks Dir: {CFG.forge_decks_dir or 'NOT SET'}")
log.info(f" Precon Dir: {CFG.precon_dir or 'NOT SET'}")
log.info(f" Lab JAR: {CFG.lab_jar or 'NOT SET'}")
log.info(f" Results: {CFG.results_dir}")
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET'}")
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET'}")
log.info(f" Java 17: {j17}")
load_commander_meta()
download_precon_database()
@@ -169,6 +311,5 @@ def main():
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
if __name__ == "__main__":
main()
-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__":
+57 -1
View File
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 120
clock: int = 300
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
@@ -114,6 +114,62 @@ class DeckGenV3Request(BaseModel):
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
+1
View File
@@ -20,6 +20,7 @@ class Config:
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
+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(
+241 -3
View File
@@ -15,6 +15,7 @@ Python simulator & DeepSeek AI opponent endpoints:
from __future__ import annotations
import json
import logging
import os
import sys
import time
@@ -33,6 +34,8 @@ from routes.shared import (
_get_deepseek_brain,
_ml_logging_enabled,
)
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
logger = logging.getLogger(__name__)
router = APIRouter(tags=["deepseek"])
@@ -40,6 +43,26 @@ router = APIRouter(tags=["deepseek"])
_sim_runs = {} # sim_id -> { status, result, error }
_sim_lock = _threading.Lock()
# ── Validator singleton ──
_validator_brain: ValidatorBrain | None = None
_validator_lock = _threading.Lock()
def _get_validator_brain() -> ValidatorBrain | None:
global _validator_brain
if os.environ.get("VALIDATOR_ENABLED", "false").lower() != "true":
return None
with _validator_lock:
if _validator_brain is None:
cfg = ValidatorConfig(
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "60.0")),
)
_validator_brain = ValidatorBrain(cfg)
return _validator_brain
# ══════════════════════════════════════════════════════════════
# Shared Helpers (extracted to deduplicate sim threads — #42)
# ══════════════════════════════════════════════════════════════
@@ -113,6 +136,11 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
name_b=opponent_name, **kw)
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
# Phase 8: Surface player_explanation from validation in turn log
for turn_entry in game_data.get('log', []):
v = turn_entry.get('validation')
if isinstance(v, dict) and v.get('player_explanation'):
turn_entry['player_explanation'] = v['player_explanation']
game_results.append(game_data)
if result.winner == 0:
@@ -182,7 +210,52 @@ def _finish_sim(sim_id, summary, game_results):
_sim_runs[sim_id]['status'] = 'complete'
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
# --- Persist batch-*.json for coach / training dashboard ---
try:
deck_name = summary.get('deckName', 'Unknown')
opponent_name = summary.get('opponentName', 'Training Deck')
batch_data = {
'format': 'python-sim',
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'simId': sim_id,
'decks': [
{
'deckName': deck_name,
'seatIndex': 0,
'commanderName': summary.get('commanderName', ''),
'colorIdentity': summary.get('colorIdentity', []),
'games': game_results,
},
{
'deckName': opponent_name,
'seatIndex': 1,
'commanderName': '',
'colorIdentity': [],
},
],
'summary': summary,
}
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
os.makedirs(results_dir, exist_ok=True)
batch_path = os.path.join(results_dir, f'batch-sim-{sim_id}.json')
with open(batch_path, 'w') as f:
json.dump(batch_data, f, indent=2, default=str)
logger.info(f'Saved batch results to {batch_path}')
# Auto-generate deck report for training dashboard / coach
try:
from coach.report_generator import generate_deck_reports
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'deck-reports')
os.makedirs(reports_dir, exist_ok=True)
generate_deck_reports(str(results_dir), str(reports_dir))
logger.info(f'Generated deck reports after sim {sim_id}')
except ImportError:
logger.warning('reports module not available; skipping deck report generation')
except Exception as e:
logger.warning(f'Could not generate deck report: {e}')
except Exception as e:
logger.error(f'Failed to persist sim results: {e}')
def _fail_sim(sim_id, error):
"""Mark a sim run as errored."""
with _sim_lock:
@@ -206,7 +279,7 @@ def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
deck_a = build_deck(decklist)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs)
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
game_results, stats, elapsed = _run_game_loop(
engine, deck_a, deck_b, deck_name, 'Training Deck',
@@ -233,7 +306,7 @@ def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
deck_a = _build_deck_from_card_data(card_data)
deck_b = _generate_training_deck()
engine = GameEngine(max_turns=25, record_log=record_logs)
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
game_results, stats, elapsed = _run_game_loop(
engine, deck_a, deck_b, deck_name, 'Training Deck',
@@ -266,9 +339,11 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
if brain and not brain._connected:
brain.check_connection()
validator = _get_validator_brain() # None if disabled
engine = DeepSeekGameEngine(
brain=brain, ai_player_index=1,
max_turns=25, record_log=record_logs)
max_turns=25, record_log=record_logs, ml_log=True,
validator=validator)
game_results, stats, elapsed = _run_game_loop(
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
@@ -295,6 +370,106 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
_fail_sim(sim_id, e)
traceback.print_exc()
# ══════════════════════════════════════════════════════════════
# N-Player Sim Thread
# ══════════════════════════════════════════════════════════════
def _run_sim_thread_n_player(sim_id: str, decks_data: list[list[dict]],
deck_names: list[str], num_games: int,
record_logs: bool, engine_type: str):
"""Background thread: N-player (2-4) batch simulation."""
try:
_ensure_src_path()
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
n_players = len(decks_data)
built_decks = [_build_deck_from_card_data(cd) for cd in decks_data]
if engine_type == 'deepseek':
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
engine = DeepSeekGameEngine(
brain=brain, ai_player_index=1,
max_turns=25, record_log=record_logs, ml_log=True)
else:
engine = GameEngine(max_turns=25, record_log=record_logs, ml_log=True)
# Per-seat accumulators
seat_wins = [0] * n_players
seat_turns = [0] * n_players
seat_damage_dealt = [0] * n_players
seat_spells_cast = [0] * n_players
seat_creatures_played = [0] * n_players
seat_removal_used = [0] * n_players
seat_ramp_played = [0] * n_players
seat_cards_drawn = [0] * n_players
seat_max_board = [0] * n_players
game_results = []
start = time.time()
for i in range(num_games):
result = engine.run_n(decks=built_decks, names=deck_names)
game_data = result.to_dict()
game_data['gameNumber'] = i + 1
game_results.append(game_data)
if 0 <= result.winner_seat < n_players:
seat_wins[result.winner_seat] += 1
for pr in result.players:
si = pr.seat_index
seat_turns[si] += result.turns
if pr.stats:
seat_damage_dealt[si] += pr.stats.damage_dealt
seat_spells_cast[si] += pr.stats.spells_cast
seat_creatures_played[si] += pr.stats.creatures_played
seat_removal_used[si] += pr.stats.removal_used
seat_ramp_played[si] += pr.stats.ramp_played
seat_cards_drawn[si] += pr.stats.cards_drawn
seat_max_board[si] += pr.stats.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
n = num_games if num_games > 0 else 1
players_summary = []
for si in range(n_players):
players_summary.append({
'seat': si,
'deckName': deck_names[si],
'wins': seat_wins[si],
'winRate': round(seat_wins[si] / n * 100, 1),
'avgTurns': round(seat_turns[si] / n, 1),
'avgDamageDealt': round(seat_damage_dealt[si] / n, 1),
'avgSpellsCast': round(seat_spells_cast[si] / n, 1),
'avgCreaturesPlayed': round(seat_creatures_played[si] / n, 1),
'avgRemovalUsed': round(seat_removal_used[si] / n, 1),
'avgRampPlayed': round(seat_ramp_played[si] / n, 1),
'avgCardsDrawn': round(seat_cards_drawn[si] / n, 1),
'avgMaxBoardSize': round(seat_max_board[si] / n, 1),
})
summary = {
'playerCount': n_players,
'totalGames': num_games,
'players': players_summary,
'elapsedSeconds': round(elapsed, 3),
}
_finish_sim(sim_id, summary, game_results)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
# ══════════════════════════════════════════════════════════════
# Simulation API Endpoints
# ══════════════════════════════════════════════════════════════
@@ -446,6 +621,43 @@ async def sim_run_deepseek(request: FastAPIRequest):
'total': num_games, 'deckName': deck_name,
'opponentType': 'deepseek'})
@router.post('/api/sim/run-n-player')
async def sim_run_n_player(request: FastAPIRequest):
"""Start an N-player (2-4) batch simulation."""
body = await request.json()
deck_ids = body.get('deckIds', [])
num_games = body.get('numGames', 10)
record_logs = body.get('recordLogs', True)
engine_type = body.get('engineType', 'heuristic')
if len(deck_ids) < 2 or len(deck_ids) > 4:
return JSONResponse({'error': '2-4 decks required'}, status_code=400)
if num_games < 1 or num_games > 1000:
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
decks_data = []
deck_names = []
for did in deck_ids:
name, cards = _fetch_deck_card_data(did)
if name is None:
return JSONResponse({'error': f'Deck {did} not found'}, status_code=404)
if not cards:
return JSONResponse({'error': f'Deck {did} has no cards'}, status_code=400)
deck_names.append(name)
decks_data.append(cards)
sim_id = _create_sim_run(num_games, ' vs '.join(deck_names))
_threading.Thread(target=_run_sim_thread_n_player,
args=(sim_id, decks_data, deck_names, num_games,
record_logs, engine_type),
daemon=True).start()
return JSONResponse({
'simId': sim_id, 'status': 'queued', 'total': num_games,
'deckNames': deck_names, 'playerCount': len(deck_ids),
})
# ══════════════════════════════════════════════════════════════
# DeepSeek AI Brain Endpoints
# ══════════════════════════════════════════════════════════════
@@ -560,3 +772,29 @@ async def deepseek_logs():
'flushedPath': flushed_path,
'logFiles': log_files[-20:],
})
@router.get("/api/validator/status")
async def validator_status():
"""Get DeepSeek-R1-14B validator brain status."""
v = _get_validator_brain()
if v is None:
return JSONResponse({"enabled": False,
"reason": "VALIDATOR_ENABLED is not true"})
return JSONResponse({"enabled": True, **v.get_stats()})
@router.post("/api/validator/configure")
async def validator_configure(request: FastAPIRequest):
"""Update validator configuration at runtime."""
body = await request.json()
v = _get_validator_brain()
if v is None:
return JSONResponse({"error": "Validator not enabled"}, status_code=400)
if "model" in body:
v.config.model = body["model"]
if "timeout" in body:
v.config.request_timeout = float(body["timeout"])
if "enabled" in body:
v.config.enabled = bool(body["enabled"])
return JSONResponse(v.get_stats())
+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
+216 -113
View File
@@ -22,6 +22,7 @@ PPO training, and tournament endpoints.
"""
from __future__ import annotations
import dataclasses
import json
import logging
import os
@@ -37,42 +38,60 @@ log_ml = logging.getLogger("commander_ai_lab.ml")
router = APIRouter(tags=["ml"])
# ---- module-level state (moved from lab_api.py) ----
# ── module-level singletons ──────────────────────────────────────────────────
_policy_service = None
_policy_service_init_attempted = False
_training_state = {
"running": False,
"progress": 0,
"total_epochs": 0,
"current_epoch": 0,
"phase": "idle",
"message": "",
"metrics": None,
"result": None,
"error": None,
"started_at": None,
}
# ── 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
_ppo_state = {
"running": False,
"iteration": 0,
"total_iterations": 0,
"phase": "idle",
"message": "",
"metrics": None,
"result": None,
"error": None,
}
def snapshot(self) -> dict:
return dataclasses.asdict(self)
_tournament_state = {
"running": False,
"phase": "idle",
"message": "",
"result": None,
"error": None,
}
@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
@@ -252,16 +271,17 @@ def _run_training_pipeline(
batch_size: int, patience: int, rebuild_dataset: bool,
):
"""Run the full ML training pipeline in a background thread."""
global _training_state, _policy_service, _policy_service_init_attempted
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)
_training_state["running"] = True
_training_state["started_at"] = datetime.now().isoformat()
_training_state["error"] = None
_training_state["result"] = None
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)
@@ -269,8 +289,9 @@ def _run_training_pipeline(
# Phase 1: Build Dataset
train_path = os.path.join(data_dir, "train.npz")
if rebuild_dataset or not os.path.exists(train_path):
_training_state["phase"] = "building"
_training_state["message"] = "Loading card embeddings & building dataset..."
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)
@@ -281,13 +302,15 @@ def _run_training_pipeline(
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"])
_training_state["message"] = f"Dataset built: {total_samples} samples"
with _training_lock:
_training_state.message = f"Dataset built: {total_samples} samples"
log_ml.info(f"Dataset built: {total_samples} samples")
# Phase 2: Train
_training_state["phase"] = "training"
_training_state["total_epochs"] = epochs
_training_state["current_epoch"] = 0
_training_state["message"] = f"Training policy network ({epochs} epochs)..."
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
@@ -312,11 +335,13 @@ def _run_training_pipeline(
checkpoint_dir=ckpt_dir,
)
summary = trainer.train(train_states, train_labels, val_states, val_labels)
_training_state["current_epoch"] = summary.get("epochs_trained", epochs)
_training_state["metrics"] = summary
with _training_lock:
_training_state.current_epoch = summary.get("epochs_trained", epochs)
_training_state.metrics = summary
# Phase 3: Evaluate
_training_state["phase"] = "evaluating"
_training_state["message"] = "Evaluating on test set..."
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):
@@ -328,15 +353,16 @@ def _run_training_pipeline(
with open(eval_path, "w") as f:
json.dump(eval_results, f, indent=2)
# Done
_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%}"
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:
@@ -346,10 +372,11 @@ def _run_training_pipeline(
_policy_service_init_attempted = False
except Exception as e:
import traceback
_training_state["phase"] = "error"
_training_state["running"] = False
_training_state["error"] = str(e)
_training_state["message"] = f"Training failed: {e}"
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()
@@ -357,7 +384,7 @@ def _run_training_pipeline(
@router.post("/api/ml/train")
async def ml_start_training(request: FastAPIRequest):
"""Trigger ML training pipeline from the web UI."""
if _training_state["running"]:
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)
@@ -366,12 +393,16 @@ async def ml_start_training(request: FastAPIRequest):
patience = body.get("patience", 10)
rebuild = body.get("rebuildDataset", True)
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
_training_state.update({
"running": True, "progress": 0, "total_epochs": epochs,
"current_epoch": 0, "phase": "starting",
"message": "Initializing training pipeline...",
"metrics": None, "result": None, "error": None,
})
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),
@@ -387,10 +418,18 @@ async def ml_start_training(request: FastAPIRequest):
}
@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."""
return dict(_training_state)
with _training_lock:
return _training_state.snapshot()
@router.get("/api/ml/data/status")
@@ -461,15 +500,15 @@ def _run_ppo_pipeline(
load_supervised: str,
):
"""Run PPO training in a background thread."""
global _ppo_state
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
_ppo_state["running"] = True
_ppo_state["error"] = None
_ppo_state["result"] = None
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(
@@ -482,30 +521,33 @@ def _run_ppo_pipeline(
)
trainer = PPOTrainer(config)
def progress_cb(iteration, metrics):
_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
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)
_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%}"
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
_ppo_state["phase"] = "error"
_ppo_state["running"] = False
_ppo_state["error"] = str(e)
_ppo_state["message"] = f"PPO failed: {e}"
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"]:
if _ppo_state.running:
raise HTTPException(409, "PPO training already in progress")
if _training_state["running"]:
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)
@@ -518,11 +560,15 @@ async def ml_start_ppo(request: FastAPIRequest):
opponent = body.get("opponent", "heuristic")
playstyle = body.get("playstyle", "midrange")
load_sup = body.get("loadSupervised", "")
_ppo_state.update({
"running": True, "iteration": 0, "total_iterations": iterations,
"phase": "starting", "message": "Initializing PPO...",
"metrics": None, "result": None, "error": None,
})
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,
@@ -536,22 +582,23 @@ async def ml_start_ppo(request: FastAPIRequest):
@router.get("/api/ml/train/ppo/status")
async def ml_ppo_status():
"""Get PPO training status."""
return dict(_ppo_state)
with _ppo_lock:
return _ppo_state.snapshot()
def _run_tournament_pipeline(episodes: int, playstyle: str):
"""Run tournament in a background thread."""
global _tournament_state
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
_tournament_state["running"] = True
_tournament_state["error"] = None
_tournament_state["result"] = None
_tournament_state["phase"] = "running"
_tournament_state["message"] = "Running tournament..."
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,
)
@@ -574,32 +621,35 @@ def _run_tournament_pipeline(episodes: int, playstyle: str):
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)
_tournament_state["phase"] = "done"
_tournament_state["running"] = False
_tournament_state["result"] = result.to_dict()
_tournament_state["message"] = f"Tournament complete! {result.total_matches} matches"
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
_tournament_state["phase"] = "error"
_tournament_state["running"] = False
_tournament_state["error"] = str(e)
_tournament_state["message"] = f"Tournament failed: {e}"
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"]:
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")
_tournament_state.update({
"running": True, "phase": "starting",
"message": "Initializing tournament...",
"result": None, "error": None,
})
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),
@@ -612,7 +662,8 @@ async def ml_start_tournament(request: FastAPIRequest):
@router.get("/api/ml/tournament/status")
async def ml_tournament_status():
"""Get tournament status."""
return dict(_tournament_state)
with _tournament_lock:
return _tournament_state.snapshot()
@router.get("/api/ml/tournament/results")
@@ -624,3 +675,55 @@ async def ml_tournament_results():
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
+3 -2
View File
@@ -186,6 +186,7 @@ from services.import_service import (
from services.precon_service import (
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database,
_get_precon_dir,
)
# ══════════════════════════════════════════════════════════════
@@ -195,9 +196,9 @@ from models.state import AI_PROFILES
# ══════════════════════════════════════════════════════════════
# Java / Sim Helpers (extracted to services/forge_runner.py)
from services.forge_runner import (
_find_java17, get_java17, build_java_command, parse_dck_file,
_find_java17, get_java17, build_java_command,
run_batch_subprocess, _run_process_blocking,
_run_deepseek_batch_thread, _get_deepseek_brain,
)
_ml_logging_enabled = False
_ml_logging_enabled = True
+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()
+4 -4
View File
@@ -15,7 +15,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
kw_raw = []
kw = " ".join(str(k).lower() for k in kw_raw)
if ("add {" in ot or "add one mana" in ot
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")
@@ -29,7 +29,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
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
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)):
@@ -47,7 +47,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
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)):
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)):
@@ -61,7 +61,7 @@ def _detect_card_roles(oracle_text: str, type_line: str, keywords) -> list:
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
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)):
+66 -29
View File
@@ -11,7 +11,6 @@ from typing import Optional
log_collect = logging.getLogger("commander_ai_lab.collection")
# ══════════════════════════════════════════════════════════════
# Collection Database
# ══════════════════════════════════════════════════════════════
@@ -20,6 +19,7 @@ _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
@@ -27,11 +27,26 @@ def _get_db_conn() -> sqlite3.Connection:
return _db_local.conn
def init_collection_db():
conn = sqlite3.connect(str(COLLECTION_DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.executescript("""
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,
@@ -95,6 +110,8 @@ def init_collection_db():
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 (
@@ -124,29 +141,32 @@ def init_collection_db():
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"),
("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()}
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")
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
conn.commit()
conn.close()
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
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",
@@ -154,8 +174,6 @@ VALID_SORT_FIELDS = {
}
def _row_to_dict(row) -> dict:
if row is None:
return None
@@ -169,12 +187,12 @@ def _row_to_dict(row) -> dict:
return d
def _snake_to_camel(s: str) -> str:
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:
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"
@@ -188,16 +206,35 @@ def _row_to_dict(row) -> dict:
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,
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:
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])
@@ -264,4 +301,4 @@ def _row_to_dict(row) -> dict:
if qtyMax is not None:
conditions.append("quantity <= ?"); params.append(qtyMax)
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
return where_str, params
return where_str, params
+84 -13
View File
@@ -34,7 +34,7 @@ def _classify_card_type(type_line: str) -> str:
return "Other"
def _get_deck_or_404(deck_id: int):
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:
@@ -47,7 +47,7 @@ def _classify_card_type(type_line: str) -> str:
return d
def _compute_deck_analysis(deck_id: int) -> dict:
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,
@@ -59,7 +59,7 @@ def _classify_card_type(type_line: str) -> str:
) 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 = {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}
@@ -96,21 +96,21 @@ def _classify_card_type(type_line: str) -> str:
}
def _check_ratio_limit(deck_id: int, card_type: str, count_to_add: int = 1) -> bool:
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:
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:
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():
@@ -153,7 +153,7 @@ def _classify_card_type(type_line: str) -> str:
return {"name": name, "commander": commander_name, "commanders": commanders, "mainboard": mainboard, "colorIdentity": [], "totalCards": total}
def _save_profile_to_dck(profile: dict) -> Path:
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}")
@@ -174,21 +174,92 @@ def _classify_card_type(type_line: str) -> str:
return out_path
def _load_deck_cards_by_name(deck_name: str) -> list:
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:
return []
deck_id = deck_row[0]
deck_id = deck_row[0]
rows = conn.execute("""
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
ce.type_line, ce.oracle_text, ce.cmc, ce.color_identity, ce.mana_cost
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
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]
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 -141
View File
@@ -1,141 +1,591 @@
"""Forge batch simulation runner: Java discovery, command building, process management."""
import asyncio
import datetime
import logging
import os
import re
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
from models.state import CFG, BatchState
from services.deck_service import parse_dck_file
log = logging.getLogger("lab.batch")
def _find_java17() -> str:
search_dirs = [r'C:\Program Files\Eclipse Adoptium', r'C:\Program Files\Java']
for d in search_dirs:
if os.path.isdir(d):
for child in os.listdir(d):
if child.startswith('jdk-17'):
candidate = os.path.join(d, child, 'bin', 'java.exe')
if os.path.isfile(candidate):
return candidate
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 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()
forge_jar = CFG.forge_jar
cmd = [
java17, '-jar', forge_jar, '-sim',
'-decks', ','.join(str(d) for d in decks),
'-games', str(num_games),
'-threads', str(threads),
'-clock', str(clock),
'-output', output_path,
]
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)}")
state.started_at = datetime.datetime.now().isoformat()
state.status = "running"
env = os.environ.copy()
java17 = get_java17()
java_bin = os.path.dirname(java17)
if java_bin:
env["JAVA_HOME"] = os.path.dirname(java_bin)
env["PATH"] = java_bin + os.pathsep + env.get("PATH", "")
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env,
)
except Exception as e:
state.status = "error"
state.error = str(e)
log.error(f"Failed to start subprocess: {e}")
return
last_activity = [time.monotonic()]
stall_limit = 300 # 5 min
def watchdog():
while proc.poll() is None:
if (time.monotonic() - last_activity[0]) > stall_limit:
log.warning("Stall detected, killing process")
proc.kill()
state.status = "error"
state.error = "Stall detected (5 min no output)"
return
time.sleep(5)
wd = threading.Thread(target=watchdog, daemon=True)
wd.start()
output_lines = []
for line in proc.stdout:
last_activity[0] = time.monotonic()
line = line.rstrip()
output_lines.append(line)
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
if gm:
state.games_done = int(gm.group(1))
state.games_total = int(gm.group(2))
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
if pm:
state.throughput = float(pm.group(1))
proc.wait()
state.log = '\n'.join(output_lines)
if proc.returncode != 0:
state.status = "error"
state.error = f"Exit code {proc.returncode}"
else:
state.status = "done"
state.games_done = state.games_total
state.ended_at = datetime.datetime.now().isoformat()
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, **kwargs,
):
"""Async wrapper that runs Forge subprocess in executor."""
cmd = build_java_command(
decks, num_games, threads, seed, clock, output_path,
use_learned_policy=use_learned_policy, **kwargs,
)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
"""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()
+230 -125
View File
@@ -1,125 +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")
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
def load_precon_index():
global PRECON_INDEX
idx_path = PRECON_DIR / "precon-index.json"
if idx_path.exists():
with open(idx_path, "r", encoding="utf-8") as f:
PRECON_INDEX = json.load(f)
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
else:
PRECON_INDEX = []
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:
global PRECON_INDEX
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:
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
if age_hours < PRECON_CACHE_HOURS:
log.info(f" Precons: {len(existing)} decks cached ({age_hours:.0f}h old)")
PRECON_INDEX = existing
return {"downloaded": 0, "skipped": True, "total": len(existing), "error": None}
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 []
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,
})
with open(idx_path, "w", encoding="utf-8") as f:
json.dump(index, f, indent=2, ensure_ascii=False)
PRECON_INDEX = index
log.info(f" Precons: {written} .dck files written, index saved")
return {"downloaded": written, "skipped": False, "total": written, "error": None}
"""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")
+13 -13
View File
@@ -11,7 +11,7 @@ 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
from services.card_analysis import _detect_card_roles as _dcr
return _dcr(oracle_text, type_line, keywords)
@@ -31,7 +31,7 @@ class _ScryfallCache:
self._misses = 0
self._init_db()
def _init_db(self):
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("""
@@ -43,11 +43,11 @@ class _ScryfallCache:
""")
self._conn.commit()
@staticmethod
@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]:
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(
@@ -59,7 +59,7 @@ class _ScryfallCache:
self._misses += 1
return None
def put(self, name: str, set_code: str, collector_number: str, data: dict):
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:
@@ -69,12 +69,12 @@ class _ScryfallCache:
)
self._conn.commit()
def stats(self) -> dict:
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:
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")
@@ -95,7 +95,7 @@ class _ScryfallCache:
return deleted
_scryfall_cache = _ScryfallCache()
_scryfall_cache = _ScryfallCache()
_scryfall_lock = threading.Lock()
_scryfall_last_call = 0.0
@@ -110,7 +110,7 @@ def _scryfall_rate_limit():
_scryfall_last_call = time.monotonic()
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
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:
@@ -133,7 +133,7 @@ def _scryfall_rate_limit():
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":
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)
@@ -143,7 +143,7 @@ def _scryfall_rate_limit():
return card_data
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
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 {}
@@ -165,7 +165,7 @@ def _scryfall_rate_limit():
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", "")
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", "")
@@ -199,7 +199,7 @@ def _scryfall_rate_limit():
}
def _scryfall_fuzzy_lookup(name: str) -> Optional[dict]:
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()
@@ -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 {}
@@ -0,0 +1,195 @@
"""
Scryfall API client for the Commander AI Deck Builder.
Provides card lookup, commander search, and color-identity-filtered
card queries using the free Scryfall REST API.
Rate-limited to 50ms between requests per Scryfall's guidelines.
"""
from __future__ import annotations
import time
from typing import Dict, List, Optional, Set
import requests
from ..core.models import CardEntry
BASE_URL = "https://api.scryfall.com"
_last_request_time: float = 0.0
REQUEST_DELAY = 0.1 # 100ms between requests (Scryfall asks for 50-100ms)
def _throttle() -> None:
"""Respect Scryfall rate limits."""
global _last_request_time
elapsed = time.time() - _last_request_time
if elapsed < REQUEST_DELAY:
time.sleep(REQUEST_DELAY - elapsed)
_last_request_time = time.time()
def _get(endpoint: str, params: Optional[Dict] = None) -> Dict:
"""Make a throttled GET request to Scryfall."""
_throttle()
resp = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=15)
resp.raise_for_status()
return resp.json()
# ── card lookup ──────────────────────────────────────────────────
def get_card_by_name(name: str) -> Optional[CardEntry]:
"""Look up a single card by exact or fuzzy name."""
try:
data = _get("/cards/named", {"fuzzy": name})
return _parse_card(data)
except requests.HTTPError:
return None
def get_card_by_id(scryfall_id: str) -> Optional[CardEntry]:
"""Look up a single card by Scryfall UUID."""
try:
data = _get(f"/cards/{scryfall_id}")
return _parse_card(data)
except requests.HTTPError:
return None
# ── commander search ─────────────────────────────────────────────
def search_commander(name: str) -> Optional[CardEntry]:
"""Find a legendary creature / commander by name."""
try:
data = _get("/cards/named", {"fuzzy": name})
type_line = data.get("type_line", "")
if "Legendary" not in type_line:
return None
return _parse_card(data)
except requests.HTTPError:
return None
# ── search cards by color identity ───────────────────────────────
def search_cards_by_identity(
color_identity: Set[str],
query_extra: str = "",
max_results: int = 100,
) -> List[CardEntry]:
"""
Search Scryfall for cards within a commander's color identity.
Uses the `id<=` operator so results only include cards whose
color identity is a subset of the commander's.
Args:
color_identity: e.g. {"W", "U", "B"}
query_extra: additional Scryfall search syntax (e.g. 't:creature')
max_results: cap on total cards returned
"""
ci_str = "".join(sorted(color_identity)) if color_identity else "C"
q = f"id<={ci_str} f:commander"
if query_extra:
q += f" {query_extra}"
cards: List[CardEntry] = []
params = {"q": q, "order": "edhrec", "dir": "asc"}
try:
data = _get("/cards/search", params)
except requests.HTTPError:
return cards
while True:
for item in data.get("data", []):
cards.append(_parse_card(item))
if len(cards) >= max_results:
return cards
if not data.get("has_more"):
break
# Scryfall provides next_page URL directly
next_url = data.get("next_page")
if not next_url:
break
_throttle()
resp = requests.get(next_url, timeout=15)
resp.raise_for_status()
data = resp.json()
return cards
# ── search by category (typed helpers) ───────────────────────────
def search_ramp(color_identity: Set[str], max_results: int = 30) -> List[CardEntry]:
"""Find mana ramp cards within color identity."""
return search_cards_by_identity(
color_identity,
query_extra='(t:artifact o:"add" o:mana) or (t:creature o:"add" o:mana) or (t:enchantment o:"add" o:mana) or (t:sorcery o:land o:"search")',
max_results=max_results,
)
def search_removal(color_identity: Set[str], max_results: int = 30) -> List[CardEntry]:
"""Find removal cards within color identity."""
return search_cards_by_identity(
color_identity,
query_extra='(o:destroy or o:exile or o:"-/-") -t:land',
max_results=max_results,
)
def search_card_draw(color_identity: Set[str], max_results: int = 30) -> List[CardEntry]:
"""Find card draw / card advantage cards."""
return search_cards_by_identity(
color_identity,
query_extra='(o:"draw a card" or o:"draw cards" or o:"draw two" or o:"draw three")',
max_results=max_results,
)
def search_lands(color_identity: Set[str], max_results: int = 45) -> List[CardEntry]:
"""Find lands within color identity."""
return search_cards_by_identity(
color_identity,
query_extra="t:land",
max_results=max_results,
)
# ── bulk name lookup (collection matching) ───────────────────────
def get_cards_by_names(names: List[str]) -> List[CardEntry]:
"""
Batch-lookup cards by name using Scryfall's /cards/collection endpoint.
Accepts up to 75 names per request.
"""
cards: List[CardEntry] = []
for i in range(0, len(names), 75):
batch = names[i : i + 75]
identifiers = [{"name": n} for n in batch]
_throttle()
resp = requests.post(
f"{BASE_URL}/cards/collection",
json={"identifiers": identifiers},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
for item in data.get("data", []):
cards.append(_parse_card(item))
return cards
# ── parser ───────────────────────────────────────────────────────
def _parse_card(data: Dict) -> CardEntry:
"""Convert a Scryfall JSON card object into a CardEntry."""
return CardEntry(
name=data.get("name", "Unknown"),
color_identity=set(data.get("color_identity", [])),
mana_cost=data.get("mana_cost"),
cmc=data.get("cmc", 0.0),
type_line=data.get("type_line"),
scryfall_id=data.get("id"),
edhrec_rank=data.get("edhrec_rank"),
source="scryfall",
)
@@ -0,0 +1,27 @@
"""Core subpackage – models, rules, and collection utilities."""
from .models import (
BuildRequest,
BuildResult,
CardEntry,
CommanderDeck,
DeckRatios,
)
from .rules_engine import (
check_ban_list,
filter_by_color_identity,
validate_deck,
)
from .collection_filter import filter_names_by_collection
__all__ = [
"BuildRequest",
"BuildResult",
"CardEntry",
"CommanderDeck",
"DeckRatios",
"check_ban_list",
"filter_by_color_identity",
"validate_deck",
"filter_names_by_collection",
]
@@ -0,0 +1,86 @@
"""
Collection filter for the Commander AI Deck Builder.
Loads a user's card collection from CSV and filters candidate
cards to only those the user owns (collection-only mode).
"""
from __future__ import annotations
import csv
import logging
from pathlib import Path
from typing import Dict, List, Optional, Set
from .models import CardEntry
logger = logging.getLogger(__name__)
def load_collection(path: str) -> Dict[str, int]:
"""
Load a card collection from CSV.
Expected CSV columns: name, quantity, [set_code]
Returns a dict of card_name -> quantity.
"""
collection: Dict[str, int] = {}
csv_path = Path(path)
if not csv_path.exists():
logger.error(f"Collection file not found: {path}")
return collection
with open(csv_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
name = row.get("name", "").strip()
if not name:
continue
qty = int(row.get("quantity", 1))
# Accumulate quantities (user may have multiple entries)
collection[name] = collection.get(name, 0) + qty
logger.info(f"Loaded collection: {len(collection)} unique cards from {path}")
return collection
def get_collection_names(path: str) -> Set[str]:
"""Load collection and return just the set of card names."""
return set(load_collection(path).keys())
def filter_by_collection(
cards: List[CardEntry],
collection_path: str,
) -> List[CardEntry]:
"""
Filter a list of CardEntry objects to only those in the collection.
Cards not found in the collection are removed.
"""
collection = load_collection(collection_path)
if not collection:
logger.warning("Empty collection — returning all cards unfiltered")
return cards
filtered = []
for card in cards:
if card.name in collection:
filtered.append(card)
else:
logger.debug(f"Filtered out (not in collection): {card.name}")
logger.info(
f"Collection filter: {len(filtered)}/{len(cards)} cards matched"
)
return filtered
def filter_names_by_collection(
names: List[str],
collection_path: str,
) -> List[str]:
"""Filter a list of card name strings to only those in the collection."""
owned = get_collection_names(collection_path)
return [n for n in names if n in owned]
@@ -0,0 +1,106 @@
"""
Deck assembler – converts Ollama JSON output into validated CardEntry lists.
Responsible for parsing the structured JSON that Ollama returns after the
ratio-enforcement step and mapping card names back to Scryfall CardEntry
objects with correct categories.
"""
from __future__ import annotations
import json
import logging
from typing import Dict, List, Optional, Tuple
from .models import CardEntry
logger = logging.getLogger(__name__)
def parse_ollama_deck_json(raw: str) -> Dict[str, List[str]]:
"""Parse the JSON blob Ollama returns into {category: [card_names]}.
Ollama is instructed to return JSON like::
{
"Creatures": ["Sol Ring", ...],
"Instants": [...],
...
}
This function tolerates minor formatting issues (trailing commas,
markdown fences, etc.).
"""
# Strip markdown code fences if present
cleaned = raw.strip()
if cleaned.startswith("```"):
# Remove opening fence (possibly ```json)
first_nl = cleaned.index("\n")
cleaned = cleaned[first_nl + 1 :]
if cleaned.endswith("```"):
cleaned = cleaned[: -3]
cleaned = cleaned.strip()
# Remove trailing commas before closing braces/brackets (common LLM error)
import re
cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
try:
data = json.loads(cleaned)
except json.JSONDecodeError as exc:
logger.error("Failed to parse Ollama deck JSON: %s", exc)
logger.debug("Raw output:\n%s", raw[:500])
raise ValueError(f"Ollama returned invalid JSON: {exc}") from exc
if not isinstance(data, dict):
raise ValueError(f"Expected dict from Ollama, got {type(data).__name__}")
result: Dict[str, List[str]] = {}
for key, val in data.items():
if isinstance(val, list):
result[key] = [str(v) for v in val]
else:
logger.warning("Skipping non-list category %s", key)
return result
def flatten_categories(categorised: Dict[str, List[str]]) -> List[str]:
"""Return a flat, deduplicated list of card names from categorised dict."""
seen: set[str] = set()
names: List[str] = []
for cards in categorised.values():
for name in cards:
lower = name.lower()
if lower not in seen:
seen.add(lower)
names.append(name)
return names
def assign_categories(
entries: List[CardEntry],
categorised: Dict[str, List[str]],
) -> List[CardEntry]:
"""Set each CardEntry's category based on the Ollama categorisation."""
name_to_cat: Dict[str, str] = {}
for cat, names in categorised.items():
for name in names:
name_to_cat[name.lower()] = cat
for entry in entries:
cat = name_to_cat.get(entry.name.lower())
if cat:
entry.category = cat
return entries
def deduplicate_entries(entries: List[CardEntry]) -> List[CardEntry]:
"""Remove duplicate card entries, keeping the first occurrence."""
seen: set[str] = set()
unique: List[CardEntry] = []
for entry in entries:
key = entry.name.lower()
if key not in seen:
seen.add(key)
unique.append(entry)
return unique
@@ -0,0 +1,145 @@
"""
Pydantic models for the Commander AI Deck Builder.
Defines CardEntry, DeckCategory, CommanderDeck, and BuildRequest
with full color identity validation and 99-card singleton enforcement.
"""
from __future__ import annotations
from enum import Enum
from typing import List, Optional, Set
from pydantic import BaseModel, Field, field_validator, model_validator
# ── colour identity ──────────────────────────────────────────────
class Color(str, Enum):
WHITE = "W"
BLUE = "U"
BLACK = "B"
RED = "R"
GREEN = "G"
WUBRG: Set[str] = {c.value for c in Color}
# ── card entry ───────────────────────────────────────────────────
class CardEntry(BaseModel):
"""A single card in a deck list."""
name: str
quantity: int = Field(default=1, ge=1)
category: str = Field(
default="uncategorized",
description="ramp | removal | card_draw | lands | synergy | protection | wincon | uncategorized",
)
color_identity: Set[str] = Field(default_factory=set)
mana_cost: Optional[str] = None
cmc: float = 0.0
type_line: Optional[str] = None
scryfall_id: Optional[str] = None
edhrec_rank: Optional[int] = None
source: Optional[str] = None # edhrec | scryfall | moxfield | archidekt | reddit | ollama
@field_validator("color_identity", mode="before")
@classmethod
def normalize_colors(cls, v):
if isinstance(v, str):
return {c.upper() for c in v if c.upper() in WUBRG}
if isinstance(v, (list, set)):
return {c.upper() for c in v if c.upper() in WUBRG}
return set()
# ── deck category targets ────────────────────────────────────────
class DeckRatios(BaseModel):
"""Target card counts per category (must sum to 99)."""
lands: int = Field(default=36, ge=30, le=42)
ramp: int = Field(default=10, ge=6, le=15)
card_draw: int = Field(default=10, ge=6, le=15)
removal: int = Field(default=8, ge=4, le=14)
protection: int = Field(default=5, ge=2, le=10)
synergy: int = Field(default=25, ge=15, le=35)
wincon: int = Field(default=3, ge=1, le=8)
uncategorized: int = Field(default=2, ge=0, le=10)
@model_validator(mode="after")
def check_total(self) -> "DeckRatios":
total = (
self.lands
+ self.ramp
+ self.card_draw
+ self.removal
+ self.protection
+ self.synergy
+ self.wincon
+ self.uncategorized
)
if total != 99:
raise ValueError(f"Card type targets must sum to 99, got {total}")
return self
# ── commander deck ───────────────────────────────────────────────
class CommanderDeck(BaseModel):
"""Full 99-card Commander deck plus the commander card."""
commander: CardEntry
companion: Optional[CardEntry] = None
cards: List[CardEntry] = Field(default_factory=list)
ratios: DeckRatios = Field(default_factory=DeckRatios)
@model_validator(mode="after")
def validate_deck(self) -> "CommanderDeck":
# ── singleton check (basic lands exempt) ──
basic_lands = {
"Plains", "Island", "Swamp", "Mountain", "Forest",
"Wastes", "Snow-Covered Plains", "Snow-Covered Island",
"Snow-Covered Swamp", "Snow-Covered Mountain", "Snow-Covered Forest",
}
seen: dict[str, int] = {}
for card in self.cards:
if card.name not in basic_lands:
seen[card.name] = seen.get(card.name, 0) + card.quantity
if seen[card.name] > 1:
raise ValueError(f"Singleton violation: '{card.name}' appears {seen[card.name]} times")
# ── 99-card check ──
total = sum(c.quantity for c in self.cards)
if total != 99:
raise ValueError(f"Deck must contain exactly 99 cards, got {total}")
# ── color identity check ──
commander_ci = self.commander.color_identity
for card in self.cards:
if card.color_identity and not card.color_identity.issubset(commander_ci):
violation = card.color_identity - commander_ci
raise ValueError(
f"'{card.name}' has colors {violation} outside commander identity {commander_ci}"
)
return self
# ── build request (API input) ────────────────────────────────────
class BuildRequest(BaseModel):
"""Input payload for the deck build pipeline."""
commander_name: str
collection_only: bool = False
collection_path: Optional[str] = None # path to CSV
budget_limit: Optional[float] = None
ratios: DeckRatios = Field(default_factory=DeckRatios)
strategy_notes: Optional[str] = None # free-text guidance for Ollama
# ── build result (API output) ────────────────────────────────────
class BuildResult(BaseModel):
"""Output payload from the deck build pipeline."""
deck: CommanderDeck
warnings: List[str] = Field(default_factory=list)
sources_consulted: List[str] = Field(default_factory=list)
build_time_seconds: float = 0.0
@@ -0,0 +1,202 @@
"""
Rules engine for the Commander AI Deck Builder.
Validates decks against Commander format rules:
- Color identity compliance
- Singleton rule (basic lands exempt)
- Exactly 99 cards + 1 commander
- Minimum card type ratios
- Ban list checking
"""
from __future__ import annotations
import logging
from typing import Dict, List, Set, Tuple
from .models import CardEntry, CommanderDeck, DeckRatios
logger = logging.getLogger(__name__)
# Commander ban list (partial — keep updated)
BANNED_CARDS: Set[str] = {
"Ancestral Recall", "Balance", "Biorhythm", "Black Lotus",
"Braids, Cabal Minion", "Channel", "Chaos Orb", "Coalition Victory",
"Emrakul, the Aeons Torn", "Erayo, Soratami Ascendant",
"Falling Star", "Fastbond", "Flash", "Gifts Ungiven",
"Golos, Tireless Pilgrim", "Griselbrand", "Hullbreacher",
"Iona, Shield of Emeria", "Karakas", "Leovold, Emissary of Trest",
"Library of Alexandria", "Limited Resources", "Lutri, the Spellchaser",
"Mox Emerald", "Mox Jet", "Mox Pearl", "Mox Ruby", "Mox Sapphire",
"Panoptic Mirror", "Paradox Engine", "Primeval Titan",
"Prophet of Kruphix", "Recurring Nightmare", "Rofellos, Llanowar Emissary",
"Shahrazad", "Sundering Titan", "Sway of the Stars",
"Sylvan Primordial", "Time Vault", "Time Walk", "Tinker",
"Tolarian Academy", "Trade Secrets", "Upheaval", "Worldfire",
}
BASIC_LANDS: Set[str] = {
"Plains", "Island", "Swamp", "Mountain", "Forest",
"Wastes", "Snow-Covered Plains", "Snow-Covered Island",
"Snow-Covered Swamp", "Snow-Covered Mountain", "Snow-Covered Forest",
}
# ── color identity validation ────────────────────────────────────
def validate_color_identity(
card: CardEntry,
commander_identity: Set[str],
) -> bool:
"""Check if a card's color identity is within the commander's."""
if not card.color_identity:
return True # colorless cards are always legal
return card.color_identity.issubset(commander_identity)
def filter_by_color_identity(
cards: List[CardEntry],
commander_identity: Set[str],
) -> Tuple[List[CardEntry], List[CardEntry]]:
"""
Split cards into valid and invalid based on color identity.
Returns (valid_cards, rejected_cards).
"""
valid = []
rejected = []
for card in cards:
if validate_color_identity(card, commander_identity):
valid.append(card)
else:
rejected.append(card)
return valid, rejected
# ── singleton validation ─────────────────────────────────────────
def validate_singleton(cards: List[CardEntry]) -> List[str]:
"""
Check for singleton violations.
Returns a list of card names that appear more than once
(excluding basic lands).
"""
seen: Dict[str, int] = {}
violations: List[str] = []
for card in cards:
if card.name in BASIC_LANDS:
continue
seen[card.name] = seen.get(card.name, 0) + card.quantity
if seen[card.name] > 1 and card.name not in violations:
violations.append(card.name)
return violations
# ── ban list check ───────────────────────────────────────────────
def check_ban_list(cards: List[CardEntry]) -> List[str]:
"""Return names of any banned cards found in the list."""
return [c.name for c in cards if c.name in BANNED_CARDS]
# ── card count validation ────────────────────────────────────────
def validate_card_count(cards: List[CardEntry], expected: int = 99) -> int:
"""Return the difference from expected count (0 = valid)."""
total = sum(c.quantity for c in cards)
return total - expected
# ── ratio validation ─────────────────────────────────────────────
def validate_ratios(
cards: List[CardEntry],
ratios: DeckRatios,
) -> Dict[str, Dict[str, int]]:
"""
Check if card categories meet minimum targets.
Returns a dict of category -> {"target": int, "actual": int, "diff": int}.
"""
counts: Dict[str, int] = {}
for card in cards:
cat = card.category
counts[cat] = counts.get(cat, 0) + card.quantity
result: Dict[str, Dict[str, int]] = {}
targets = {
"lands": ratios.lands,
"ramp": ratios.ramp,
"card_draw": ratios.card_draw,
"removal": ratios.removal,
"protection": ratios.protection,
"synergy": ratios.synergy,
"wincon": ratios.wincon,
"uncategorized": ratios.uncategorized,
}
for cat, target in targets.items():
actual = counts.get(cat, 0)
result[cat] = {
"target": target,
"actual": actual,
"diff": actual - target,
}
return result
# ── full deck validation ─────────────────────────────────────────
def validate_deck(
commander: CardEntry,
cards: List[CardEntry],
ratios: DeckRatios,
) -> Dict[str, any]:
"""
Run all validation checks on a deck.
Returns a dict with:
- "valid": bool
- "errors": list of fatal errors
- "warnings": list of non-fatal warnings
- "ratio_report": category ratio details
"""
errors: List[str] = []
warnings: List[str] = []
# Color identity
commander_ci = commander.color_identity
_, rejected = filter_by_color_identity(cards, commander_ci)
if rejected:
names = [c.name for c in rejected]
errors.append(f"Color identity violations: {', '.join(names)}")
# Singleton
dupes = validate_singleton(cards)
if dupes:
errors.append(f"Singleton violations: {', '.join(dupes)}")
# Ban list
banned = check_ban_list(cards)
if banned:
errors.append(f"Banned cards: {', '.join(banned)}")
# Card count
diff = validate_card_count(cards)
if diff != 0:
direction = "too many" if diff > 0 else "too few"
errors.append(f"Card count: {abs(diff)} {direction} (need exactly 99)")
# Ratios
ratio_report = validate_ratios(cards, ratios)
for cat, info in ratio_report.items():
if info["diff"] < 0:
warnings.append(
f"{cat}: {info['actual']}/{info['target']} "
f"({abs(info['diff'])} short)"
)
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"ratio_report": ratio_report,
}
+139
View File
@@ -0,0 +1,139 @@
"""
FastAPI + CLI entrypoint for the Commander AI Deck Builder.
Run with:
uvicorn commander_ai_lab.deck_builder.main:app --reload
Or via CLI:
python -m commander_ai_lab.deck_builder.main --commander "Atraxa, Praetors' Voice"
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from .core.models import BuildRequest, BuildResult
from .pipeline.build_deck import build_deck
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(
title="Commander AI Deck Builder",
version="0.1.0",
description="Generate optimised 99-card Commander decks using local Ollama AI.",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health():
"""Liveness probe."""
return {"status": "ok"}
@app.post("/build", response_model=BuildResult)
async def api_build_deck(request: BuildRequest):
"""Build a Commander deck from the given request."""
try:
result = build_deck(request)
return result
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Unhandled error during deck build")
raise HTTPException(status_code=500, detail=str(exc)) from exc
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def cli() -> None:
"""Minimal CLI wrapper around build_deck."""
parser = argparse.ArgumentParser(description="Commander AI Deck Builder CLI")
parser.add_argument("--commander", required=True, help="Commander card name")
parser.add_argument(
"--collection",
default=None,
help="Path to collection CSV (optional)",
)
parser.add_argument(
"--collection-only",
action="store_true",
default=False,
help="Only use cards from collection",
)
parser.add_argument(
"--model",
default="gpt-oss:20b",
help="Ollama model name (default: gpt-oss:20b)",
)
parser.add_argument(
"--output",
default=None,
help="Output JSON file path (default: stdout)",
)
args = parser.parse_args()
# Load collection if provided
collection_names: list[str] | None = None
if args.collection:
import csv
from pathlib import Path
path = Path(args.collection)
if not path.exists():
print(f"Error: collection file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, newline="", encoding="utf-8") as fh:
reader = csv.DictReader(fh)
name_col = "Name" if "Name" in (reader.fieldnames or []) else "name"
collection_names = [row[name_col] for row in reader if row.get(name_col)]
request = BuildRequest(
commander_name=args.commander,
collection_names=collection_names,
collection_only=args.collection_only,
)
logger.info("Building deck for %s …", args.commander)
result = build_deck(request)
output = result.model_dump_json(indent=2)
if args.output:
from pathlib import Path as P
P(args.output).write_text(output, encoding="utf-8")
logger.info("Deck written to %s", args.output)
else:
print(output)
if result.warnings:
for w in result.warnings:
logger.warning("⚠ %s", w)
if __name__ == "__main__":
cli()
@@ -0,0 +1,5 @@
"""Pipeline subpackage – exposes the public build_deck entry point."""
from .build_deck import build_deck
__all__ = ["build_deck"]
@@ -0,0 +1,212 @@
"""
7-step deck build pipeline for the Commander AI Deck Builder.
Orchestrates: EDHrec data -> Scryfall -> Ollama suggestions ->
color filter -> synergy rank -> ratio enforce -> final assembly.
"""
from __future__ import annotations
import logging
import time
from typing import Dict, List, Optional
from ..api import edhrec, scryfall
from ..api import ollama_client as ollama
from ..core.collection_filter import filter_names_by_collection
from ..core.models import BuildRequest, BuildResult, CardEntry, CommanderDeck, DeckRatios
from ..core.rules_engine import check_ban_list, filter_by_color_identity, validate_deck
logger = logging.getLogger(__name__)
def build_deck(request: BuildRequest) -> BuildResult:
"""
Execute the full 7-step deck build pipeline.
Steps:
1. Resolve commander via Scryfall
2. Fetch EDHrec recommendations
3. Fetch Scryfall candidates by category
4. Ollama: suggest additional cards + fill gaps
5. Filter by color identity + collection + ban list
6. Ollama: enforce deck ratios (trim/expand to 99)
7. Ollama: assemble final structured deck JSON
"""
start = time.time()
warnings: List[str] = []
sources: List[str] = []
# ── Step 1: Resolve commander ────────────────────────────────
logger.info(f"Step 1: Resolving commander '{request.commander_name}'")
commander = scryfall.search_commander(request.commander_name)
if not commander:
commander = scryfall.get_card_by_name(request.commander_name)
if not commander:
raise ValueError(f"Commander not found: {request.commander_name}")
commander_ci = commander.color_identity
ci_list = sorted(commander_ci)
logger.info(f"Commander: {commander.name}, CI: {ci_list}")
sources.append("scryfall")
# ── Step 2: Fetch EDHrec recommendations ─────────────────────
logger.info(f"Step 2: Fetching EDHrec data for '{commander.name}'")
edhrec_cards = edhrec.get_recommended_cards(commander.name)
if edhrec_cards:
sources.append("edhrec")
logger.info(f"EDHrec returned {len(edhrec_cards)} cards")
else:
warnings.append("EDHrec returned no data — relying on Scryfall + Ollama")
# ── Step 3: Fetch Scryfall candidates by category ────────────
logger.info("Step 3: Fetching Scryfall candidates by category")
scryfall_candidates: Dict[str, List[CardEntry]] = {
"ramp": scryfall.search_ramp(commander_ci),
"removal": scryfall.search_removal(commander_ci),
"card_draw": scryfall.search_card_draw(commander_ci),
"lands": scryfall.search_lands(commander_ci),
}
for cat, cards in scryfall_candidates.items():
logger.info(f" Scryfall {cat}: {len(cards)} candidates")
# ── Step 4: Ollama suggestions to fill gaps ──────────────────
logger.info("Step 4: Ollama suggesting additional cards")
# Collect all names we already have
all_names: List[str] = [c.name for c in edhrec_cards]
for cards in scryfall_candidates.values():
all_names.extend(c.name for c in cards)
all_names = list(set(all_names))
# Ask Ollama to suggest cards for categories that need more
categories_to_fill = ["synergy", "protection", "wincon"]
ollama_suggestions: Dict[str, List[str]] = {}
for category in categories_to_fill:
suggested = ollama.suggest_cards(
commander_name=commander.name,
color_identity=ci_list,
category=category,
count=15,
exclude=all_names,
strategy_notes=request.strategy_notes,
)
ollama_suggestions[category] = suggested
all_names.extend(suggested)
logger.info(f" Ollama {category}: {len(suggested)} suggestions")
sources.append("ollama")
# ── Step 5: Filter (color identity, collection, ban list) ────
logger.info("Step 5: Filtering candidates")
# Build a unified name pool per category
cards_by_category: Dict[str, List[str]] = {}
# EDHrec cards (already categorized)
for card in edhrec_cards:
cat = card.category
cards_by_category.setdefault(cat, []).append(card.name)
# Scryfall candidates
for cat, cards in scryfall_candidates.items():
cards_by_category.setdefault(cat, []).extend(c.name for c in cards)
# Ollama suggestions
for cat, names in ollama_suggestions.items():
cards_by_category.setdefault(cat, []).extend(names)
# Deduplicate within each category
for cat in cards_by_category:
cards_by_category[cat] = list(dict.fromkeys(cards_by_category[cat]))
# Collection filter (if enabled)
if request.collection_only and request.collection_path:
logger.info(" Applying collection filter")
for cat in cards_by_category:
before = len(cards_by_category[cat])
cards_by_category[cat] = filter_names_by_collection(
cards_by_category[cat], request.collection_path
)
after = len(cards_by_category[cat])
if before != after:
logger.info(f" {cat}: {before} -> {after} after collection filter")
# Ban list filter
for cat in cards_by_category:
from ..core.rules_engine import BANNED_CARDS
cards_by_category[cat] = [
n for n in cards_by_category[cat] if n not in BANNED_CARDS
]
# ── Step 6: Ollama enforce ratios ────────────────────────────
logger.info("Step 6: Ollama enforcing deck ratios")
target_ratios = {
"lands": request.ratios.lands,
"ramp": request.ratios.ramp,
"card_draw": request.ratios.card_draw,
"removal": request.ratios.removal,
"protection": request.ratios.protection,
"synergy": request.ratios.synergy,
"wincon": request.ratios.wincon,
"uncategorized": request.ratios.uncategorized,
}
adjusted = ollama.enforce_deck_ratios(
cards_by_category=cards_by_category,
target_ratios=target_ratios,
commander_name=commander.name,
)
# Verify total is 99
total = sum(len(v) for v in adjusted.values())
if total != 99:
warnings.append(f"Ollama returned {total} cards instead of 99 — adjusting")
logger.warning(f"Ratio enforcement returned {total} cards")
# ── Step 7: Assemble final deck ──────────────────────────────
logger.info("Step 7: Assembling final deck")
# Convert to CardEntry list via Scryfall batch lookup
all_card_names = []
for names in adjusted.values():
all_card_names.extend(names)
card_entries = scryfall.get_cards_by_names(all_card_names)
# Map back categories
name_to_cat: Dict[str, str] = {}
for cat, names in adjusted.items():
for name in names:
name_to_cat[name] = cat
for entry in card_entries:
if entry.name in name_to_cat:
entry.category = name_to_cat[entry.name]
# Build the deck
elapsed = time.time() - start
logger.info(f"Deck built in {elapsed:.1f}s")
try:
deck = CommanderDeck(
commander=commander,
cards=card_entries,
ratios=request.ratios,
)
except ValueError as e:
warnings.append(f"Deck validation issue: {e}")
# Return a "best effort" deck without strict validation
deck = CommanderDeck.model_construct(
commander=commander,
cards=card_entries,
ratios=request.ratios,
)
return BuildResult(
deck=deck,
warnings=warnings,
sources_consulted=sources,
build_time_seconds=elapsed,
)
+6 -5
View File
@@ -9,7 +9,7 @@ The LLM receives a structured game-state snapshot and returns a JSON
action describing what to do this turn (play land, cast spell, attack,
hold/pass, use removal, use board wipe, cast ramp, cast commander).
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at localhost:11434
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at 192.168.0.240:11434
ollama run gpt-oss:20b
Also supports:
@@ -58,17 +58,17 @@ class DeepSeekConfig:
Example — LM Studio::
config = DeepSeekConfig(
api_base="http://192.168.0.122:1234",
api_base="http://192.168.0.240:11434",
model="deepseek-r1-distill-qwen-8b",
max_tokens=2048, # R1 needs room for <think> blocks
)
Example — Ollama (default)::
config = DeepSeekConfig() # points at localhost:11434 / gpt-oss:20b
config = DeepSeekConfig() # points at 192.168.0.240:11434 / gpt-oss:20b
"""
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
api_base: str = "http://localhost:11434"
api_base: str = "http://192.168.0.240:11434"
# Model name as registered in Ollama
model: str = "gpt-oss:20b"
@@ -784,7 +784,8 @@ class DeepSeekBrain:
"source": result.get("source"),
"latency_ms": result.get("latency_ms"),
},
"game_result": None,
"validation": result.get("validation"),
"game_result": None,
}
self._decision_log.append(entry)
+35 -4
View File
@@ -23,7 +23,7 @@ Usage::
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.122:1234"))
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.240:11434"))
brain.check_connection()
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
@@ -39,6 +39,9 @@ from typing import Optional
from commander_ai_lab.sim.models import Card, GameResult, Player, PlayerResult, PlayerStats, SimState
from commander_ai_lab.sim.rules import AI_DEFAULT_WEIGHTS, enrich_card, score_card
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig, build_deck_context
import os
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
from commander_ai_lab.sim.deepseek_brain import build_game_state_snapshot
logger = logging.getLogger("deepseek_engine")
@@ -54,12 +57,13 @@ class DeepSeekGameEngine:
def __init__(
self,
brain: DeepSeekBrain | None = None,
ai_player_index: int = 1, # Which player is the LLM AI (0 or 1)
ai_player_index: int = 1,
max_turns: int = 25,
starting_life: int = 40,
weights: Optional[dict] = None,
record_log: bool = False,
ml_log: bool = False, # Capture ML decision snapshots
ml_log: bool = False,
validator: ValidatorBrain | None = None, # NEW
):
self.brain = brain or DeepSeekBrain()
self.ai_player_index = ai_player_index
@@ -68,7 +72,16 @@ class DeepSeekGameEngine:
self.weights = weights or AI_DEFAULT_WEIGHTS
self.record_log = record_log
self.ml_log = ml_log
self.ml_decisions: list[dict] = [] # Collected ML decision snapshots
self.ml_decisions: list[dict] = []
# Validator: use injected instance, or auto-create from env
if validator is not None:
self._validator = validator
elif os.environ.get("VALIDATOR_ENABLED", "false").lower() == "true":
cfg = ValidatorConfig.from_env()
self._validator = ValidatorBrain(cfg)
else:
self._validator = None
def run(
self,
@@ -247,6 +260,24 @@ class DeepSeekGameEngine:
# Ask DeepSeek what to do — with full deck intelligence
decision = self.brain.choose_action(sim, pi, turn, available_mana, deck_context=deck_context)
# -- Optional validator call (DeepSeek-R1-14B) --
if self._validator is not None:
try:
snapshot = build_game_state_snapshot(
sim, pi, turn,
deck_context=self._deck_context,
)
except Exception:
snapshot = {}
validation = self._validator.validate(snapshot, decision)
if validation:
decision["validation"] = validation
if validation.get("legality") == "illegal":
logger.warning(
"Validator flagged illegal action on turn %d: %s",
turn, validation.get("legality_issues"),
)
action = decision.get("action", "hold")
target_card_name = decision.get("target_card", "")
reasoning = decision.get("reasoning", "")
+32
View File
@@ -12,6 +12,9 @@ scale with pod size (e.g. Propaganda is worth more vs 3 opponents).
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from typing import Optional
@@ -72,6 +75,35 @@ AI_DEFAULT_WEIGHTS: dict[str, float] = {
"commander_bonus": 5.0,
}
_log = logging.getLogger(__name__)
# Default path for learned weights (written by ml/scripts/update_weights.py)
_LEARNED_WEIGHTS_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), 'learned_weights.json'
)
def load_weights(path: Optional[str] = None) -> dict[str, float]:
"""Load AI weights from a JSON file, falling back to AI_DEFAULT_WEIGHTS.
If *path* is None the default ``learned_weights.json`` next to this
module is checked. If the file does not exist or is invalid the
built-in defaults are returned silently.
"""
fpath = path or _LEARNED_WEIGHTS_PATH
if os.path.isfile(fpath):
try:
with open(fpath, 'r', encoding='utf-8') as f:
learned = json.load(f)
if isinstance(learned, dict):
merged = dict(AI_DEFAULT_WEIGHTS)
merged.update(learned)
_log.info('Loaded learned weights from %s (%d keys)', fpath, len(learned))
return merged
except Exception as exc:
_log.warning('Failed to load learned weights from %s: %s', fpath, exc)
return dict(AI_DEFAULT_WEIGHTS)
# ══════════════════════════════════════════════════════════════
# Card Enrichment — guess type/cost for cards without Scryfall data
+290
View File
@@ -0,0 +1,290 @@
"""
src/commander_ai_lab/sim/validator_brain.py
===========================================
DeepSeek-R1-Distill-Qwen-14B validator / explainer brain.
Called AFTER GPT-OSS 20B chooses an action. Receives the game-state
snapshot and the chosen action, then returns:
- legality check (legal / illegal / unclear)
- tactical quality assessment
- suggested improvement (or null)
- player_explanation: 2-5 sentences suitable for display in the UI
Strips <think> blocks automatically (R1-style reasoning models always
emit them). Falls back silently on timeout so it never blocks the
game loop.
Environment variables (all optional -- see ValidatorConfig defaults):
VALIDATOR_ENABLED true/false (default: false)
VALIDATOR_API_BASE Ollama/LM-Studio base URL (default: http://localhost:11434)
VALIDATOR_MODEL model name (default: deepseek-r1:14b)
VALIDATOR_TEMPERATURE generation temperature (default: 0.0)
VALIDATOR_MAX_TOKENS max output tokens (default: 2048)
VALIDATOR_TIMEOUT request timeout seconds (default: 60.0)
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from urllib.request import Request, urlopen
logger = logging.getLogger("validator_brain")
# ==============================================================
# Configuration
# ==============================================================
@dataclass
class ValidatorConfig:
"""
Configuration for the R1-14B validator brain.
All fields can be overridden via environment variables at runtime
(see module docstring). Defaults target deepseek-r1:14b served by
Ollama on localhost.
Example -- LM Studio::
cfg = ValidatorConfig(
api_base="http://192.168.0.240:1234",
model="deepseek-r1-distill-qwen-14b",
)
"""
api_base: str = "http://localhost:11434"
model: str = "deepseek-r1:14b"
temperature: float = 0.0
max_tokens: int = 2048
request_timeout: float = 60.0
enabled: bool = True
@classmethod
def from_env(cls) -> "ValidatorConfig":
"""Build a ValidatorConfig from environment variables."""
enabled_str = os.environ.get("VALIDATOR_ENABLED", "false").lower()
return cls(
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
temperature=float(os.environ.get("VALIDATOR_TEMPERATURE", "0.0")),
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "60.0")),
enabled=(enabled_str == "true"),
)
# ==============================================================
# Prompts
# ==============================================================
VALIDATOR_SYSTEM_PROMPT = """You are an expert Magic: The Gathering Commander rules judge and strategy coach.
You will receive:
1. A JSON game-state snapshot.
2. A proposed action chosen by an AI opponent.
Your tasks:
1. LEGALITY CHECK -- verify the proposed action is rules-legal given the game state.
Check: does the player have the mana to cast the target card? Is the target card
actually in hand? Is the action type appropriate (e.g., no attacking on turn 1
with a creature played this turn without haste)?
2. TACTICAL QUALITY -- evaluate how strong the action is for the active player given
their archetype, threat level, and hand.
3. EXPLANATION -- write a clear 2-5 sentence explanation suitable for showing to
a human player watching the simulation.
IMPORTANT:
- Think through your reasoning internally using <think> blocks.
- Then output ONLY the JSON object below -- no other text outside the JSON.
Output this exact JSON structure:
{
"legality": "legal",
"legality_issues": [],
"tactical_assessment": "short paragraph describing tactical quality",
"suggested_improvement": null,
"player_explanation": "2-5 sentence explanation of what the AI did and why"
}
Rules:
- Set "legality" to "legal", "illegal", or "unclear".
- Set "legality_issues" to a list of issue strings if illegal/unclear, otherwise [].
- Set "suggested_improvement" to a one-sentence alternative if a clearly better line
exists, otherwise null.
- Keep "player_explanation" concise, friendly, and informative for a human audience.
"""
VALIDATOR_USER_TEMPLATE = """Game state:
{game_state_json}
Chosen action:
{action_json}
Validate this action and provide your analysis. Respond with ONLY valid JSON."""
# ==============================================================
# ValidatorBrain
# ==============================================================
class ValidatorBrain:
"""
Thin LLM client for the DeepSeek-R1-14B validator role.
Designed to be called after GPT-OSS 20B chooses an action.
Never raises -- returns None on timeout or parse failure so the
game loop is never blocked.
Usage::
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
cfg = ValidatorConfig.from_env() # reads VALIDATOR_* env vars
validator = ValidatorBrain(cfg)
result = validator.validate(game_state_snapshot, chosen_action)
if result:
print(result["player_explanation"])
"""
def __init__(self, config: ValidatorConfig | None = None):
self.config = config or ValidatorConfig.from_env()
self._total_calls: int = 0
self._total_failures: int = 0
self._total_latency_ms: float = 0.0
# -- Public API ----------------------------------------
def validate(
self,
game_state_snapshot: dict,
chosen_action: dict,
) -> dict | None:
"""
Validate a chosen action against the current game state.
Returns a dict with keys:
legality -- "legal" | "illegal" | "unclear"
legality_issues -- list[str]
tactical_assessment -- str
suggested_improvement -- str | None
player_explanation -- str
Returns None on timeout, connection error, or JSON parse failure.
The caller should silently continue with the original action.
"""
if not self.config.enabled:
return None
self._total_calls += 1
t_start = time.time()
game_state_json = json.dumps(game_state_snapshot, indent=2)
action_json = json.dumps(chosen_action, indent=2)
user_msg = VALIDATOR_USER_TEMPLATE.format(
game_state_json=game_state_json,
action_json=action_json,
)
payload = {
"model": self.config.model,
"messages": [
{"role": "system", "content": VALIDATOR_SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
"temperature": self.config.temperature,
"max_tokens": self.config.max_tokens,
"stream": False,
}
try:
url = self.config.api_base.rstrip("/") + "/v1/chat/completions"
req = Request(
url,
data=json.dumps(payload).encode("utf-8"),
method="POST",
)
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=self.config.request_timeout) as resp:
data = json.loads(resp.read())
raw = data["choices"][0]["message"]["content"]
result = self._parse_response(raw)
latency = round((time.time() - t_start) * 1000, 1)
self._total_latency_ms += latency
logger.debug(
"Validator latency: %sms legality=%s",
latency, result.get("legality"),
)
return result
except Exception as exc:
self._total_failures += 1
logger.warning("Validator call failed (proceeding without validation): %s", exc)
return None
def check_connection(self) -> bool:
"""Test whether the validator endpoint is reachable."""
try:
url = self.config.api_base.rstrip("/") + "/v1/models"
req = Request(url, method="GET")
req.add_header("Content-Type", "application/json")
with urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
models = data.get("data", [])
if models:
logger.info(
"Validator connected to %s model=%s",
self.config.api_base, self.config.model,
)
return True
except Exception as exc:
logger.warning("Validator connection check failed: %s", exc)
return False
def get_stats(self) -> dict:
"""Return performance statistics for monitoring/API endpoints."""
successful = self._total_calls - self._total_failures
avg_latency = round(
self._total_latency_ms / max(successful, 1), 1
)
return {
"total_calls": self._total_calls,
"failures": self._total_failures,
"successful": successful,
"avg_latency_ms": avg_latency,
"model": self.config.model,
"api_base": self.config.api_base,
"enabled": self.config.enabled,
}
# -- Internal helpers ----------------------------------
def _parse_response(self, raw_text: str) -> dict:
"""Strip <think> blocks and markdown fences, then parse JSON."""
text = raw_text.strip()
# Strip R1-style <think>...</think> reasoning blocks
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
# Strip any unclosed <think> block trailing the response
text = re.sub(r"<think>.*", "", text, flags=re.DOTALL).strip()
# Strip markdown code fences
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text).strip()
# Extract outermost JSON object if any surrounding text leaked through
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
text = match.group(0)
parsed = json.loads(text)
return {
"legality": parsed.get("legality", "unclear"),
"legality_issues": parsed.get("legality_issues", []),
"tactical_assessment": parsed.get("tactical_assessment", ""),
"suggested_improvement": parsed.get("suggested_improvement"),
"player_explanation": parsed.get("player_explanation", ""),
}
@@ -75,11 +75,11 @@ public class BatchRunner {
// "Game Outcome: Ai(1)-Edgar Markov has won because all opponents have lost"
private static final Pattern WINNER_PATTERN =
Pattern.compile("Game Outcome:\\s*Ai\\((\\d+)\\)-(.+?)\\s+has won because (.+)");
Pattern.compile("Game Outcome:\\s*Ai\\(?(\\d+)\\)?-(.+?)\\s+has won because (.+)");
// "Game Outcome: Ai(2)-Grimgrin has lost because life total reached 0"
private static final Pattern LOSER_PATTERN =
Pattern.compile("Game Outcome:\\s*Ai\\((\\d+)\\)-(.+?)\\s+has lost because (.+)");
Pattern.compile("Game Outcome:\\s*Ai\\(?(\\d+)\\)?-(.+?)\\s+has lost because (.+)");
// "Game Result: Game 1 ended in 9201 ms. Ai(1)-Edgar Markov has won!"
private static final Pattern GAME_RESULT_PATTERN =
@@ -97,19 +97,19 @@ public class BatchRunner {
// Forge verbose log format: "Life: Life: Ai(1)-Name 40 > 42" (old life > new life)
// Also support older format: "Ai(1)-Name's life is now 27."
private static final Pattern LIFE_CHANGE_PATTERN =
Pattern.compile("Life:\\s*Life:\\s*Ai\\((\\d+)\\)-.+?\\s+(\\d+)\\s*>\\s*(-?\\d+)");
Pattern.compile("Life:\\s*Life:\\s*Ai\\(?(\\d+)\\)?-.+?\\s+(\\d+)\\s*>\\s*(-?\\d+)");
// Fallback: "Ai(1)-Name's life is now 27." or "Ai(1)-Name's life total is now 27"
private static final Pattern LIFE_TOTAL_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-.+?'s\\s+life(?:\\s+total)?\\s+is\\s+now\\s+(-?\\d+)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\(?(\\d+)\\)?-.+?'s\\s+life(?:\\s+total)?\\s+is\\s+now\\s+(-?\\d+)", Pattern.CASE_INSENSITIVE);
// "Ai(1)-Name loses N life" — life loss event
private static final Pattern LIFE_LOSS_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+loses\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\(?(\\d+)\\)?-.+?\\s+loses\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
// "Ai(1)-Name gains N life" — life gain event
private static final Pattern LIFE_GAIN_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-.+?\\s+gains\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\(?(\\d+)\\)?-.+?\\s+gains\\s+(\\d+)\\s+life", Pattern.CASE_INSENSITIVE);
// ── Verbose game-log patterns for extracting combat stats ────────────
@@ -117,12 +117,12 @@ public class BatchRunner {
// Format: "Add To Stack: Ai(N)-DeckName cast CardName"
// Note: Forge uses past tense "cast" not "casts"
private static final Pattern CAST_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+cast\\s+(.+?)(?:\\s+targeting.*)?$", Pattern.CASE_INSENSITIVE);
Pattern.compile("(?:Add To Stack:\\s*)?Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+casts?\\s+(.+?)(?:\\s+targeting.*)?$", Pattern.CASE_INSENSITIVE);
// "Land: Ai(1)-Name played LandName (SET)" or "Ai(1)-Name plays LandName."
// Forge verbose log uses "played" (past tense) with set info in parens, no trailing period
private static final Pattern LAND_PLAY_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s+\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("(?:Land:\\s*)?Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+play(?:s|ed)\\s+(.+?)(?:\\s+\\(\\d+\\))?(?:\\.|$)", Pattern.CASE_INSENSITIVE);
// "Zone Change: CardName (N) was put into Graveyard from Battlefield." — creature/permanent death
// Also: "is destroyed" / "dies" from older format
@@ -133,11 +133,15 @@ public class BatchRunner {
// "deals N combat damage" or "deals N damage to Ai(X)"
private static final Pattern DAMAGE_PATTERN =
Pattern.compile("deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\((\\d+)\\)", Pattern.CASE_INSENSITIVE);
Pattern.compile("deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
// "Damage: CardName deals 3 combat damage to Ai(2)-DeckName." — captures source card for commander damage tracking
private static final Pattern PLAYER_DAMAGE_SOURCE_PATTERN =
Pattern.compile("(?:Damage:\\s*)?(.+?)\\s+deals\\s+(\\d+)\\s+(?:combat\\s+)?damage\\s+to\\s+Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
// "commander damage" — specifically commander damage dealt
private static final Pattern CMDR_DAMAGE_PATTERN =
Pattern.compile("Ai\\((\\d+)\\).*?commander.*?damage.*?(\\d+)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\(?(\\d+)\\)?.*?commander.*?damage.*?(\\d+)", Pattern.CASE_INSENSITIVE);
// ── Per-card tracking patterns (verbose log) ─────────────────────────
@@ -145,12 +149,12 @@ public class BatchRunner {
// This pattern is kept as a fallback for other Forge output modes.
// In verbose mode, draws can only be inferred from "Draw step" phase entries.
private static final Pattern DRAW_PATTERN =
Pattern.compile("Ai\\((\\d+)\\)-[^\\s].*?\\s+draws\\s+(.+?)(?:\\.|$)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Ai\\(?(\\d+)\\)?-[^\\s].*?\\s+draws\\s+(.+?)(?:\\.|$)", Pattern.CASE_INSENSITIVE);
// Turn boundary: "Turn 5 (Ai(1)-Name)" or "Turn 5 (Ai(2)-Name)"
// Some Forge versions use: "== Turn X (Ai(N)-Name) =="
private static final Pattern VERBOSE_TURN_PATTERN =
Pattern.compile("Turn\\s+(\\d+)\\s+\\(Ai\\((\\d+)\\)", Pattern.CASE_INSENSITIVE);
Pattern.compile("Turn\\s+(\\d+)\\s+\\(Ai\\(?(\\d+)\\)?", Pattern.CASE_INSENSITIVE);
// "CardName deals N damage" — damage by a specific card
private static final Pattern CARD_DAMAGE_PATTERN =
@@ -582,9 +586,11 @@ public class BatchRunner {
Set<Integer> loserSeats = new HashSet<>();
Set<Integer> winnerCandidates = new HashSet<>(); // Track all "has won" seats
int maxVerboseTurn = 0; // Fallback turn count from verbose log
for (String line : lines) {
line = line.trim();
if (line.startsWith("Game Outcome:") || line.startsWith("Game Result:") || line.startsWith("Match Result:")) {
System.out.println("[OUTCOME-LINE] " + line);
}
// Parse turn count: "Game Outcome: Turn 11"
Matcher turnMatcher = TURN_PATTERN.matcher(line);
@@ -713,7 +719,7 @@ public class BatchRunner {
// Count mulligans from non-quiet output
if (line.toLowerCase().contains("mulligan")) {
Matcher mullMatcher = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
Matcher mullMatcher = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
if (mullMatcher.find()) {
int aiNum = Integer.parseInt(mullMatcher.group(1));
int seat = aiNum - 1;
@@ -791,8 +797,32 @@ public class BatchRunner {
for (int ci = 0; ci < decks.size(); ci++) {
commanderNames[ci] = decks.get(ci).commanderName;
}
// Auto-detect commander names from .dck files if not explicitly set
for (int ci2 = 0; ci2 < decks.size(); ci2++) {
if (commanderNames[ci2] == null || commanderNames[ci2].isEmpty()
|| commanderNames[ci2].equals(decks.get(ci2).deckName)
|| commanderNames[ci2].equals(decks.get(ci2).deckFile)) {
String detected = readCommanderCardName(decks.get(ci2).deckFile);
if (detected != null && !detected.isEmpty()) {
commanderNames[ci2] = detected;
System.out.printf("[CMDR] Seat %d: auto-detected commander '%s' from .dck file%n", ci2, detected);
}
}
}
// Log commander names for diagnostics
for (int ci3 = 0; ci3 < decks.size(); ci3++) {
System.out.printf("[CMDR-INFO] Seat %d: commander='%s', deckName='%s'%n",
ci3, commanderNames[ci3], decks.get(ci3).deckName);
}
for (String line : lines) {
// Diagnostic: log lines containing combat-related keywords (first 10 only)
String lower = line.trim().toLowerCase();
if (lower.contains("cast") || lower.contains("play") || lower.contains("damage") || lower.contains("add to stack") || lower.contains("land:")) {
System.out.println("[COMBAT-LINE] " + line.trim());
}
line = line.trim();
if (line.isEmpty()) continue;
@@ -824,18 +854,30 @@ public class BatchRunner {
continue;
}
// Match damage dealt: "deals N damage to Ai(X)"
Matcher dmgMatcher = DAMAGE_PATTERN.matcher(line);
while (dmgMatcher.find()) {
// Note: this captures damage TO a player, not FROM.
// We track it as creature/combat damage events
// Match damage dealt to players and track commander damage
Matcher playerDmgMatcher = PLAYER_DAMAGE_SOURCE_PATTERN.matcher(line);
if (playerDmgMatcher.find()) {
String sourceCard = playerDmgMatcher.group(1).trim();
int dmgAmount = Integer.parseInt(playerDmgMatcher.group(2));
int targetAiNum = Integer.parseInt(playerDmgMatcher.group(3));
int targetSeat = targetAiNum - 1;
// Check if the source card is any player's commander
for (int ci = 0; ci < decks.size(); ci++) {
if (commanderNames[ci] != null && sourceCard.equalsIgnoreCase(commanderNames[ci])) {
if (ci >= 0 && ci < result.playerResults.size()) {
result.playerResults.get(ci).commanderDamageDealt += dmgAmount;
System.out.printf("[CMDR-DMG] %s (seat %d) dealt %d cmdr damage to seat %d%n",
sourceCard, ci, dmgAmount, targetSeat);
}
break;
}
}
}
// Match creature deaths: "is destroyed" / "dies"
if (CREATURE_DESTROYED_PATTERN.matcher(line).find()) {
// Try to figure out which player's creature died
// Look for Ai(N) reference before the destruction text
Matcher aiRef = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
Matcher aiRef = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
// We attribute destruction to the OPPOSING player (the one who caused it)
// But since we can't reliably determine the attacker, count it globally
// For each seat, count creatures they destroyed (opponent's creatures that died)
@@ -860,7 +902,7 @@ public class BatchRunner {
if (cdmg.find()) {
int amount = Integer.parseInt(cdmg.group(1));
// Try to find the source player
Matcher srcPlayer = Pattern.compile("Ai\\((\\d+)\\)").matcher(line);
Matcher srcPlayer = Pattern.compile("Ai\\(?(\\d+)\\)?").matcher(line);
if (srcPlayer.find()) {
int seat = Integer.parseInt(srcPlayer.group(1)) - 1;
if (seat >= 0 && seat < decks.size()) {
@@ -879,6 +921,14 @@ public class BatchRunner {
// Leave at 0 rather than fabricating data
}
System.out.printf("[PARSE-SUMMARY] Game %d: total lines=%d%n", gameIndex, lines.length);
// ── Diagnostic: log parsed combat stats ──────────────────────
for (int seat = 0; seat < result.playerResults.size(); seat++) {
PlayerResult pr = result.playerResults.get(seat);
System.out.printf("[PARSE] Game %d Seat %d: spells=%d, lands=%d, cmdrCasts=%d, cmdrDmg=%d, life=%d%n",
gameIndex, seat, pr.spellsCast, pr.landsPlayed, pr.commanderCasts, pr.commanderDamageDealt, pr.finalLife);
}
// ── Build per-card stats from verbose output (for coach analytics) ──
if (!quiet) {
try {
@@ -1267,4 +1317,49 @@ public class BatchRunner {
}
return result;
}
/**
* Read the commander card name from a Forge .dck file.
* Looks for the [Commander] section and extracts the card name.
* Format: "1 Card Name|SET" under [Commander]
*/
private String readCommanderCardName(String deckFilePath) {
try {
Path path = Path.of(deckFilePath);
if (!Files.exists(path)) {
Path withExt = Path.of(deckFilePath + ".dck");
if (Files.exists(withExt)) {
path = withExt;
} else {
Path resolved = resolveForgeDecksPath(deckFilePath);
if (resolved != null && Files.exists(resolved)) {
path = resolved;
} else {
return null;
}
}
}
List<String> lines = Files.readAllLines(path);
boolean inCommanderSection = false;
Pattern cardLine = Pattern.compile("^(\\d+)\\s+(.+?)(?:\\|(.+))?$");
for (String line : lines) {
line = line.trim();
if (line.isEmpty() || line.startsWith("//")) continue;
if (line.startsWith("[") && line.endsWith("]")) {
inCommanderSection = line.equalsIgnoreCase("[Commander]");
continue;
}
if (line.startsWith("Name=")) continue;
if (inCommanderSection) {
Matcher m = cardLine.matcher(line);
if (m.matches()) {
return m.group(2).trim();
}
}
}
} catch (Exception e) {
System.err.println("[CMDR] Failed to read commander from .dck: " + deckFilePath + " - " + e.getMessage());
}
return null;
}
}
@@ -0,0 +1,235 @@
package commanderailab.bridge;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.*;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* GameSession — holds state for one live interactive Commander game.
*
* PHASE 1 STUB: generates synthetic game state so you can verify the full
* WebSocket contract round-trips correctly before wiring in real Forge IPC.
*
* Replace run() in Phase 2 once the contract is verified end-to-end.
*/
public class GameSession {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final List<String> deckNames;
private final Long seed;
private final String forgeJarPath;
private final String forgeWorkDir;
private final BlockingQueue<String> humanActionQueue;
private volatile boolean running = false;
private volatile int turnNumber = 1;
// Mutable game state
private String phase = "UNTAP";
private int activePlayer = 0;
private int priorityPlayer = 0;
private final int[] life;
private final int[] poison;
private final List<String>[] hands;
private final List<String>[] battlefields;
private final List<String>[] graveyards;
private final List<String>[] commandZones;
private final int[] commanderTax;
private boolean awaitingHumanInput = false;
private static final String[] PHASES = {
"UNTAP", "UPKEEP", "DRAW",
"MAIN1", "BEGIN_COMBAT", "DECLARE_ATTACKERS",
"DECLARE_BLOCKERS", "DAMAGE", "END_COMBAT",
"MAIN2", "END", "CLEANUP"
};
private int phaseIndex = 0;
@SuppressWarnings("unchecked")
public GameSession(List<String> deckNames, Long seed,
String forgeJarPath, String forgeWorkDir,
BlockingQueue<String> humanActionQueue) {
this.deckNames = deckNames;
this.seed = seed;
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
this.humanActionQueue = humanActionQueue;
int seats = Math.min(deckNames.size(), 4);
life = new int[seats];
poison = new int[seats];
hands = new List[seats];
battlefields = new List[seats];
graveyards = new List[seats];
commandZones = new List[seats];
commanderTax = new int[seats];
for (int i = 0; i < seats; i++) {
life[i] = 40;
hands[i] = new ArrayList<>(List.of(
"card_stub_1", "card_stub_2", "card_stub_3",
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
));
battlefields[i] = new ArrayList<>();
graveyards[i] = new ArrayList<>();
commandZones[i] = new ArrayList<>(List.of(deckNames.get(i) + "_commander"));
}
}
public boolean isRunning() { return running; }
public int getTurnNumber() { return turnNumber; }
public void stop() {
running = false;
}
/**
* Run the game loop. Calls onStateChange after every action resolves.
* Blocks until the game ends or stop() is called.
*
* PHASE 1 STUB: advances through phases automatically; on MAIN1 of seat 0's
* turn, pauses and waits for the human client to send an action.
*/
public void run(Consumer<Object> onStateChange) throws InterruptedException {
running = true;
Random rng = seed != null ? new Random(seed) : new Random();
System.out.println("[Session] Game started. Decks: " + deckNames);
while (running && !isGameOver()) {
String currentPhase = PHASES[phaseIndex];
phase = currentPhase;
activePlayer = (turnNumber - 1) % deckNames.size();
boolean isHumanTurn = (activePlayer == 0);
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
if (isHumanTurn && isInteractivePhase) {
awaitingHumanInput = true;
priorityPlayer = 0;
onStateChange.accept(buildStateSnapshot());
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
if (actionJson == null) {
System.out.println("[Session] Human timed out — auto passing.");
} else {
applyAction(actionJson);
System.out.println("[Session] Human action applied: " + actionJson);
}
awaitingHumanInput = false;
} else {
// AI turn — small delay for readability
Thread.sleep(300);
// Simulate AI occasionally dealing damage to human
if (!isHumanTurn && currentPhase.equals("DAMAGE") && life[0] > 5) {
life[0] -= rng.nextInt(4);
}
}
// Advance phase
phaseIndex++;
if (phaseIndex >= PHASES.length) {
phaseIndex = 0;
turnNumber++;
}
onStateChange.accept(buildStateSnapshot());
}
running = false;
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
}
private void applyAction(String actionJson) {
try {
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
switch (type) {
case "PLAY_LAND", "CAST_SPELL" -> {
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
if (cardId != null && hands[0].contains(cardId)) {
hands[0].remove(cardId);
battlefields[0].add(cardId);
}
}
case "PASS_PRIORITY" -> { /* no-op, phase advances naturally */ }
default -> System.out.println("[Session] Unknown action type: " + type);
}
} catch (Exception e) {
System.err.println("[Session] Failed to apply action: " + e.getMessage());
}
}
private boolean isGameOver() {
int alive = 0;
for (int lp : life) if (lp > 0) alive++;
// Phase 1 stub: end after 50 turns or only 1 player alive
return alive <= 1 || turnNumber > 50;
}
// ---------------------------------------------------------
// State serialization
// ---------------------------------------------------------
/** Returns a full { type: "STATE", state: {...} } JSON string. */
public String buildStateMessage() {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "STATE");
msg.put("state", buildStateSnapshot());
return GSON.toJson(msg);
}
/** Returns the raw state snapshot map (serialized by caller). */
public Map<String, Object> buildStateSnapshot() {
Map<String, Object> state = new LinkedHashMap<>();
state.put("schema", "1.0.0");
state.put("phase", phase);
state.put("turnNumber", turnNumber);
state.put("activePlayer", activePlayer);
state.put("priorityPlayer", priorityPlayer);
state.put("awaitingInput", awaitingHumanInput);
state.put("inputPrompt", awaitingHumanInput ? "Choose an action or pass priority" : null);
List<Map<String, Object>> players = new ArrayList<>();
for (int i = 0; i < deckNames.size(); i++) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("seat", i);
p.put("name", i == 0 ? "Human" : "AI-" + i);
p.put("isAI", i != 0);
p.put("deckName", deckNames.get(i));
p.put("life", life[i]);
p.put("poison", poison[i]);
p.put("commanderTax", commanderTax[i]);
p.put("handCount", hands[i].size());
p.put("hand", i == 0 ? hands[i] : List.of()); // hide opponent hands
p.put("battlefield", battlefields[i]);
p.put("graveyard", graveyards[i]);
p.put("commandZone", commandZones[i]);
p.put("manaPool", Map.of("W",0,"U",0,"B",0,"R",0,"G",0,"C",0));
players.add(p);
}
state.put("players", players);
state.put("stack", List.of());
// Legal actions — only populated when it's the human's turn
List<Map<String, Object>> legalActions = new ArrayList<>();
if (awaitingHumanInput) {
legalActions.add(Map.of("type", "PASS_PRIORITY", "label", "Pass Priority"));
for (String card : hands[0]) {
legalActions.add(Map.of("type", "CAST_SPELL", "cardId", card, "label", "Cast " + card));
}
}
state.put("legalActions", legalActions);
return state;
}
}
@@ -0,0 +1,204 @@
package commanderailab.bridge;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
import java.net.InetSocketAddress;
import java.util.*;
import java.util.concurrent.*;
/**
* GameWsServer — WebSocket server that exposes the Forge engine to a Unity/Godot client.
*
* Connect at: ws://localhost:7654
*
* Protocol:
* ENGINE -> CLIENT: GameStateSnapshot JSON (pushed after every action resolves)
* CLIENT -> ENGINE: PlayerAction JSON
*
* Message types (CLIENT -> ENGINE):
* { "type": "START_GAME", "decks": ["DeckA","DeckB","DeckC","DeckD"], "seed": 12345 }
* { "type": "PLAYER_ACTION", "action": { ... } }
* { "type": "PING" }
*
* Message types (ENGINE -> CLIENT):
* { "type": "STATE", "state": { ... GameStateSnapshot ... } }
* { "type": "ERROR", "message": "..." }
* { "type": "PONG" }
*/
public class GameWsServer extends WebSocketServer {
private static final int DEFAULT_PORT = 7654;
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private final String forgeJarPath;
private final String forgeWorkDir;
// Active game session (one game at a time for Phase 1)
private volatile GameSession activeSession;
// Waiting-for-human-input: the session parks here until Unity sends an action
private final BlockingQueue<String> humanActionQueue = new LinkedBlockingQueue<>();
public GameWsServer(String forgeJarPath, String forgeWorkDir) {
super(new InetSocketAddress(DEFAULT_PORT));
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
setReuseAddr(true);
}
public GameWsServer(int port, String forgeJarPath, String forgeWorkDir) {
super(new InetSocketAddress(port));
this.forgeJarPath = forgeJarPath;
this.forgeWorkDir = forgeWorkDir;
setReuseAddr(true);
}
// ---------------------------------------------------------
// WebSocket lifecycle
// ---------------------------------------------------------
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
System.out.println("[WS] Client connected: " + conn.getRemoteSocketAddress());
// Send current state if a game is in progress
if (activeSession != null) {
conn.send(activeSession.buildStateMessage());
}
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
System.out.println("[WS] Client disconnected: " + conn.getRemoteSocketAddress());
}
@Override
public void onMessage(WebSocket conn, String message) {
try {
JsonObject msg = JsonParser.parseString(message).getAsJsonObject();
String type = msg.get("type").getAsString();
switch (type) {
case "PING" -> conn.send(buildPong());
case "START_GAME" -> handleStartGame(conn, msg);
case "PLAYER_ACTION" -> handlePlayerAction(conn, msg);
case "STOP_GAME" -> {
if (activeSession != null) activeSession.stop();
activeSession = null;
broadcast(buildInfo("Game stopped."));
}
default -> sendError(conn, "Unknown message type: " + type);
}
} catch (Exception e) {
sendError(conn, "Parse error: " + e.getMessage());
}
}
@Override
public void onError(WebSocket conn, Exception ex) {
System.err.println("[WS] Error: " + ex.getMessage());
}
@Override
public void onStart() {
System.out.println("[WS] GameWsServer started on port " + getPort());
System.out.println("[WS] Connect Unity/Godot to: ws://localhost:" + getPort());
}
// ---------------------------------------------------------
// Message handlers
// ---------------------------------------------------------
private void handleStartGame(WebSocket conn, JsonObject msg) {
if (activeSession != null && activeSession.isRunning()) {
sendError(conn, "A game is already in progress. Send { type: 'STOP_GAME' } first.");
return;
}
List<String> decks = new ArrayList<>();
if (msg.has("decks")) {
msg.getAsJsonArray("decks").forEach(d -> decks.add(d.getAsString()));
}
if (decks.size() < 3) {
sendError(conn, "Need at least 3 deck names in 'decks' array.");
return;
}
Long seed = msg.has("seed") ? msg.get("seed").getAsLong() : null;
System.out.println("[WS] Starting game: " + decks + " seed=" + seed);
// Clear any stale actions from previous game
humanActionQueue.clear();
activeSession = new GameSession(decks, seed, forgeJarPath, forgeWorkDir, humanActionQueue);
// Push initial state immediately
broadcast(activeSession.buildStateMessage());
// Run the game loop on a background thread so WebSocket thread stays free
Thread gameThread = new Thread(() -> {
try {
activeSession.run(state -> broadcast(GSON.toJson(state)));
broadcast(buildInfo("Game over after " + activeSession.getTurnNumber() + " turns."));
} catch (Exception e) {
broadcastError("Game error: " + e.getMessage());
e.printStackTrace();
}
}, "game-loop");
gameThread.setDaemon(true);
gameThread.start();
}
private void handlePlayerAction(WebSocket conn, JsonObject msg) {
if (activeSession == null || !activeSession.isRunning()) {
sendError(conn, "No active game. Send START_GAME first.");
return;
}
if (!msg.has("action")) {
sendError(conn, "Missing 'action' field.");
return;
}
// Put the raw action JSON string onto the queue -- GameSession polls this
humanActionQueue.offer(msg.get("action").toString());
}
// ---------------------------------------------------------
// Broadcast helpers
// ---------------------------------------------------------
private void broadcastError(String message) {
broadcast(buildError(message));
}
private void sendError(WebSocket conn, String message) {
conn.send(buildError(message));
}
private static String buildError(String message) {
JsonObject obj = new JsonObject();
obj.addProperty("type", "ERROR");
obj.addProperty("message", message);
return obj.toString();
}
private static String buildInfo(String message) {
JsonObject obj = new JsonObject();
obj.addProperty("type", "INFO");
obj.addProperty("message", message);
return obj.toString();
}
private static String buildPong() {
JsonObject obj = new JsonObject();
obj.addProperty("type", "PONG");
obj.addProperty("ts", System.currentTimeMillis());
return obj.toString();
}
}
@@ -0,0 +1,62 @@
package commanderailab.cli;
import commanderailab.bridge.GameWsServer;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import java.util.concurrent.Callable;
/**
* WsServerCommand — picocli subcommand that starts the interactive game WebSocket server.
*
* Usage:
* java -jar commander-ai-lab.jar ws-server \
* --forge-jar path/to/forge.jar \
* --forge-dir path/to/forge-gui \
* --port 7654
*
* Then connect Unity/Godot to ws://localhost:7654 and send:
* { "type": "START_GAME", "decks": ["DeckA","DeckB","DeckC","DeckD"], "seed": 42 }
*/
@Command(
name = "ws-server",
mixinStandardHelpOptions = true,
description = "Start the interactive game WebSocket server for Unity/Godot clients."
)
public class WsServerCommand implements Callable<Integer> {
@Option(names = {"--forge-jar", "-F"}, required = true,
description = "Path to Forge desktop JAR (forge-gui-desktop-XXX-jar-with-dependencies.jar)")
private String forgeJarPath;
@Option(names = {"--forge-dir", "-W"}, required = true,
description = "Forge working directory (folder containing res/)")
private String forgeWorkDir;
@Option(names = {"--port", "-p"}, defaultValue = "7654",
description = "WebSocket port (default: 7654)")
private int port;
@Override
public Integer call() throws Exception {
System.out.println("╔══════════════════════════════════════════════════╗");
System.out.println("║ Commander AI Lab — Interactive WS Server ║");
System.out.println("╚══════════════════════════════════════════════════╝");
System.out.println();
System.out.println(" Forge JAR : " + forgeJarPath);
System.out.println(" Forge Dir : " + forgeWorkDir);
System.out.println(" WS Port : " + port);
System.out.println();
GameWsServer server = new GameWsServer(port, forgeJarPath, forgeWorkDir);
server.start();
System.out.println(" Ready — connect Unity/Godot to ws://localhost:" + port);
System.out.println(" Press Ctrl+C to stop.");
System.out.println();
// Block until killed
Thread.currentThread().join();
return 0;
}
}
@@ -92,6 +92,13 @@ public class BatchResult {
public double winRate;
public Double avgTurnsToWin; // null if zero wins
public double avgMulligans;
// v5: Aggregate combat stats from per-game PlayerResult data
public double avgSpellsCast;
public double avgLandsPlayed;
public double avgCommanderCasts;
public double avgCommanderDamageDealt;
public double avgCreaturesDestroyed;
public double avgFinalLife;
public WinConditionBreakdown winConditionBreakdown;
}
@@ -28,6 +28,11 @@ public class StatsAggregator {
long[] totalTurnsOnWin = new long[podSize];
long[] totalMulligans = new long[podSize];
long[] totalFinalLife = new long[podSize];
long[] totalSpellsCast = new long[podSize];
long[] totalLandsPlayed = new long[podSize];
long[] totalCommanderCasts = new long[podSize];
long[] totalCommanderDamageDealt = new long[podSize];
long[] totalCreaturesDestroyed = new long[podSize];
int[] gameCount = new int[podSize];
WinConditionBreakdown[] breakdowns = new WinConditionBreakdown[podSize];
for (int i = 0; i < podSize; i++) {
@@ -46,6 +51,11 @@ public class StatsAggregator {
gameCount[seat]++;
totalMulligans[seat] += pr.mulligans;
totalFinalLife[seat] += pr.finalLife;
totalSpellsCast[seat] += pr.spellsCast;
totalLandsPlayed[seat] += pr.landsPlayed;
totalCommanderCasts[seat] += pr.commanderCasts;
totalCommanderDamageDealt[seat] += pr.commanderDamageDealt;
totalCreaturesDestroyed[seat] += pr.creaturesDestroyed;
if (pr.isWinner) {
wins[seat]++;
@@ -70,6 +80,11 @@ public class StatsAggregator {
ds.draws = draws[i];
ds.winRate = completedGames > 0 ? (double) wins[i] / completedGames : 0.0;
ds.avgTurnsToWin = wins[i] > 0 ? (double) totalTurnsOnWin[i] / wins[i] : null;
ds.avgSpellsCast = gameCount[i] > 0 ? (double) totalSpellsCast[i] / gameCount[i] : 0.0;
ds.avgLandsPlayed = gameCount[i] > 0 ? (double) totalLandsPlayed[i] / gameCount[i] : 0.0;
ds.avgCommanderCasts = gameCount[i] > 0 ? (double) totalCommanderCasts[i] / gameCount[i] : 0.0;
ds.avgCommanderDamageDealt = gameCount[i] > 0 ? (double) totalCommanderDamageDealt[i] / gameCount[i] : 0.0;
ds.avgCreaturesDestroyed = gameCount[i] > 0 ? (double) totalCreaturesDestroyed[i] / gameCount[i] : 0.0;
ds.avgMulligans = gameCount[i] > 0 ? (double) totalMulligans[i] / gameCount[i] : 0.0;
ds.avgFinalLife = gameCount[i] > 0 ? (double) totalFinalLife[i] / gameCount[i] : 0.0;
ds.winConditionBreakdown = breakdowns[i];
+1
View File
@@ -0,0 +1 @@
# deck_builder tests
+104
View File
@@ -0,0 +1,104 @@
"""Tests for DeckBuilderAdapter — the bridge between Ollama pipeline and legacy API."""
import pytest
from unittest.mock import patch, MagicMock
from types import SimpleNamespace
from src.commander_ai_lab.deck_builder.adapter import DeckBuilderAdapter
from src.commander_ai_lab.deck_builder.core.models import (
BuildResult,
CardEntry,
CommanderDeck,
DeckRatios,
)
def _fake_build_result():
"""Return a minimal BuildResult for mocking build_deck."""
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
basics = ["Plains", "Island", "Swamp", "Mountain", "Forest"]
cards = [
CardEntry(name=basics[i % 5], quantity=1, category="lands", color_identity={"W", "U", "B", "G"})
for i in range(99)
]
deck = CommanderDeck(commander=cmdr, cards=cards)
return BuildResult(
deck=deck,
warnings=["test warning"],
sources_consulted=["scryfall", "edhrec"],
build_time_seconds=1.23,
)
class TestDeckBuilderAdapter:
def test_init_defaults(self):
adapter = DeckBuilderAdapter()
assert adapter.model == "gpt-oss:20b"
assert adapter.db_conn_factory is None
@patch("src.commander_ai_lab.deck_builder.adapter.build_deck")
def test_generate_deck_returns_expected_shape(self, mock_build):
mock_build.return_value = _fake_build_result()
adapter = DeckBuilderAdapter()
result = adapter.generate_deck("Atraxa", strategy="superfriends")
assert "commander" in result
assert result["commander"]["name"] == "Atraxa"
assert "cards" in result
assert "stats" in result
assert result["model"] == "gpt-oss:20b"
assert result["build_time_seconds"] == 1.23
assert "scryfall" in result["reasoning"]["sources"]
@patch("src.commander_ai_lab.deck_builder.adapter.build_deck")
def test_generate_deck_fills_to_100(self, mock_build):
mock_build.return_value = _fake_build_result()
adapter = DeckBuilderAdapter()
result = adapter.generate_deck("Atraxa")
total = sum(c.get("count", 1) for c in result["cards"])
assert total == 100 # 99 cards + commander fills to 100
@patch("src.commander_ai_lab.deck_builder.adapter.build_deck")
def test_ownership_without_db(self, mock_build):
mock_build.return_value = _fake_build_result()
adapter = DeckBuilderAdapter(db_conn_factory=None)
result = adapter.generate_deck("Atraxa")
for card in result["cards"]:
assert card["owned"] is False
assert card["from_collection"] is False
class TestRunSubstitution:
def test_no_op_returns_namespace(self):
adapter = DeckBuilderAdapter()
cards = [
{"name": "Sol Ring", "owned": True},
{"name": "Mana Crypt", "owned": False},
]
result = adapter.run_substitution(cards, commander="Atraxa")
assert isinstance(result, SimpleNamespace)
assert result.owned_count == 1
assert result.substituted_count == 0
assert result.missing_count == 1
class TestHelpers:
def test_fill_basic_lands_adds_missing(self):
cards = [{"name": "Sol Ring", "count": 1, "category": "ramp"}]
filled = DeckBuilderAdapter._fill_basic_lands(cards, ["W", "U"], target_total=5)
total = sum(c.get("count", 1) for c in filled)
assert total == 5
def test_fill_basic_lands_no_excess(self):
cards = [{"name": f"Card{i}", "count": 1} for i in range(100)]
filled = DeckBuilderAdapter._fill_basic_lands(cards, ["W"], target_total=100)
assert len(filled) == 100
def test_compute_stats(self):
cards = [
{"name": "Sol Ring", "count": 1, "category": "ramp", "cmc": 1},
{"name": "Plains", "count": 36, "category": "lands", "cmc": 0},
]
stats = DeckBuilderAdapter._compute_stats(cards)
assert stats["total_cards"] == 37
assert stats["land_count"] == 36
assert stats["average_cmc"] == 1.0
@@ -0,0 +1 @@
# Tests for collection_filter — Phase 5
+162
View File
@@ -0,0 +1,162 @@
"""Tests for the Pydantic models used by the Ollama deck builder."""
import pytest
from pydantic import ValidationError
from src.commander_ai_lab.deck_builder.core.models import (
BuildRequest,
BuildResult,
CardEntry,
Color,
CommanderDeck,
DeckRatios,
WUBRG,
)
# ── Color enum ───────────────────────────────────────────────────
class TestColor:
def test_wubrg_set_has_five_colors(self):
assert WUBRG == {"W", "U", "B", "R", "G"}
def test_enum_values(self):
assert Color.WHITE.value == "W"
assert Color.BLUE.value == "U"
assert Color.GREEN.value == "G"
# ── CardEntry ────────────────────────────────────────────────────
class TestCardEntry:
def test_minimal_card(self):
card = CardEntry(name="Sol Ring")
assert card.name == "Sol Ring"
assert card.quantity == 1
assert card.category == "uncategorized"
assert card.cmc == 0.0
def test_color_identity_from_string(self):
card = CardEntry(name="Counterspell", color_identity="UB")
assert card.color_identity == {"U", "B"}
def test_color_identity_from_list(self):
card = CardEntry(name="Swords to Plowshares", color_identity=["W"])
assert card.color_identity == {"W"}
def test_invalid_colors_stripped(self):
card = CardEntry(name="Test", color_identity=["W", "X", "Z"])
assert card.color_identity == {"W"}
def test_full_card(self):
card = CardEntry(
name="Rhystic Study",
quantity=1,
category="card_draw",
color_identity=["U"],
mana_cost="{2}{U}",
cmc=3.0,
type_line="Enchantment",
scryfall_id="abc-123",
edhrec_rank=5,
source="edhrec",
)
assert card.source == "edhrec"
assert card.edhrec_rank == 5
# ── DeckRatios ───────────────────────────────────────────────────
class TestDeckRatios:
def test_default_sums_to_99(self):
ratios = DeckRatios()
total = (
ratios.lands + ratios.ramp + ratios.card_draw
+ ratios.removal + ratios.protection + ratios.synergy
+ ratios.wincon + ratios.uncategorized
)
assert total == 99
def test_bad_total_raises(self):
with pytest.raises(ValidationError, match="must sum to 99"):
DeckRatios(lands=50)
def test_out_of_range_raises(self):
with pytest.raises(ValidationError):
DeckRatios(lands=5) # ge=30
# ── CommanderDeck ────────────────────────────────────────────────
def _make_99_cards(ci=None):
"""Helper: build a valid 99-card list of basic lands."""
ci = ci or set()
cards = []
basics = ["Plains", "Island", "Swamp", "Mountain", "Forest"]
for i in range(99):
cards.append(CardEntry(
name=basics[i % 5],
quantity=1,
category="lands",
color_identity=ci,
))
return cards
class TestCommanderDeck:
def test_valid_deck(self):
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
deck = CommanderDeck(
commander=cmdr,
cards=_make_99_cards({"W", "U", "B", "G"}),
)
assert len(deck.cards) == 99
def test_wrong_card_count_raises(self):
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
with pytest.raises(ValidationError, match="exactly 99 cards"):
CommanderDeck(commander=cmdr, cards=_make_99_cards()[:50])
def test_singleton_violation_raises(self):
cmdr = CardEntry(name="Atraxa", color_identity=list(WUBRG))
cards = _make_99_cards(WUBRG)[:97]
cards.append(CardEntry(name="Sol Ring", color_identity=set()))
cards.append(CardEntry(name="Sol Ring", color_identity=set()))
with pytest.raises(ValidationError, match="Singleton violation"):
CommanderDeck(commander=cmdr, cards=cards)
def test_color_identity_violation_raises(self):
cmdr = CardEntry(name="Mono White", color_identity=["W"])
cards = _make_99_cards({"W"})[:98]
cards.append(CardEntry(name="Counterspell", color_identity=["U"]))
with pytest.raises(ValidationError, match="outside commander identity"):
CommanderDeck(commander=cmdr, cards=cards)
# ── BuildRequest / BuildResult ───────────────────────────────────
class TestBuildRequest:
def test_minimal(self):
req = BuildRequest(commander_name="Atraxa")
assert req.commander_name == "Atraxa"
assert req.collection_only is False
assert req.budget_limit is None
def test_with_strategy(self):
req = BuildRequest(
commander_name="Atraxa",
strategy_notes="superfriends planeswalker tribal",
)
assert "superfriends" in req.strategy_notes
class TestBuildResult:
def test_minimal(self):
cmdr = CardEntry(name="Atraxa", color_identity=["W", "U", "B", "G"])
deck = CommanderDeck(
commander=cmdr,
cards=_make_99_cards({"W", "U", "B", "G"}),
)
result = BuildResult(deck=deck)
assert result.build_time_seconds == 0.0
assert result.warnings == []
assert result.sources_consulted == []
+1
View File
@@ -0,0 +1 @@
# Tests for rules_engine — Phase 5
+257
View File
@@ -0,0 +1,257 @@
"""
tests/test_deckgen_color_filter.py
===================================
Regression tests for the SQL-based colour-identity filtering in
_get_collection_for_colors() and _build_collection_summary().
These tests use an in-memory SQLite database seeded with known data so they
run without touching the real collection DB.
"""
from __future__ import annotations
import json
import sqlite3
from unittest.mock import patch
import pytest
# ── Helpers ──────────────────────────────────────────────────────────────────
def _make_conn() -> sqlite3.Connection:
"""Return a seeded in-memory DB matching the collection_entries schema."""
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("""
CREATE TABLE 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'))
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ce_quantity
ON collection_entries (quantity) WHERE quantity > 0
""")
# Seed known cards
cards = [
# (name, color_identity, quantity, scryfall_id, type_line, oracle_text, cmc, mana_cost, keywords, edhrec_rank, tcg_price, category)
("Sol Ring", '[]', 2, "sol-001", "Artifact", "Tap: Add {C}{C}.", 0, "{1}", '[]', 1, 5.0, "ramp"),
("Plains", '[]', 4, "plains-001", "Basic Land - Plains", "({T}: Add {W}.)", 0, "", '[]', 0, 0.1, ""),
("Swords to Plow", '["W"]', 1, "stp-001", "Instant", "Exile target creature.", 1, "{W}", '[]', 100, 3.0, "removal"),
("Counterspell", '["U"]', 1, "cs-001", "Instant", "Counter target spell.", 2, "{U}{U}", '[]', 200, 2.0, "removal"),
("Damnation", '["B"]', 1, "damn-001", "Sorcery", "Destroy all creatures.", 4, "{2}{B}{B}", '[]', 300, 15.0, "board wipe"),
("Lightning Bolt", '["R"]', 1, "bolt-001", "Instant", "Deals 3 damage.", 1, "{R}", '[]', 150, 1.0, "removal"),
("Llanowar Elves", '["G"]', 3, "llano-001", "Creature - Elf Druid", "Tap: Add {G}.", 1, "{G}", '[]', 50, 0.5, "ramp"),
("Atraxa", '["W","U","B","G"]', 1, "atrax-001", "Legendary Creature", "Proliferate.", 4, "{W}{U}{B}{G}", '["Proliferate"]', 10, 25.0, ""),
("Kozilek", '[]', 1, "kozi-001", "Legendary Creature - Eldrazi", "Draw 4 cards.", 10, "{10}", '[]', 20, 30.0, ""),
("Zero-Qty Card", '["W"]', 0, "zero-001", "Creature", "Some text.", 2, "{1}{W}", '[]', 5000, 0.5, ""),
("No-ID Card", '["R"]', 1, "", "Creature", "Some text.", 3, "{2}{R}", '[]', 9999, 0.1, ""),
("Orzhov Signet", '["W","B"]', 2, "orzh-001", "Artifact", "Add {W} or {B}.", 2, "{2}", '[]', 80, 1.0, "ramp"),
]
conn.executemany("""
INSERT INTO collection_entries
(name, color_identity, quantity, scryfall_id, type_line, oracle_text,
cmc, mana_cost, keywords, edhrec_rank, tcg_price, category)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", cards)
conn.commit()
return conn
def _query_for_colors(conn: sqlite3.Connection, color_identity: list) -> list:
"""Run the same SQL as _get_collection_for_colors() against the given conn."""
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 _summary_for_colors(conn: sqlite3.Connection, color_identity: list | None) -> list:
"""Run the same SQL as _build_collection_summary() and return matched rows."""
allowed_json = json.dumps([c.upper() for c in color_identity]) if color_identity else '[]'
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 quantity > 0
{ci_filter}
ORDER BY edhrec_rank ASC, tcg_price DESC
""", params).fetchall()
return [dict(r) for r in rows]
# ── Tests ────────────────────────────────────────────────────────────────────
class TestGetCollectionForColors:
def setup_method(self):
self.conn = _make_conn()
# Test 1: Mono-W — white cards, colourless cards, no off-colour
def test_mono_white(self):
result = _query_for_colors(self.conn, ["W"])
names = {c["name"] for c in result}
assert "Sol Ring" in names # colourless
assert "Plains" in names # colourless land
assert "Swords to Plow" in names # white
assert "Kozilek" in names # colourless creature
assert "Counterspell" not in names # blue
assert "Damnation" not in names # black
assert "Lightning Bolt" not in names # red
assert "Llanowar Elves" not in names # green
assert "Atraxa" not in names # WUBG (needs more than W)
assert "Orzhov Signet" not in names # WB (B not in W)
# Test 2: WUBRG — returns all cards with qty > 0 and scryfall_id
def test_five_color(self):
result = _query_for_colors(self.conn, ["W", "U", "B", "R", "G"])
names = {c["name"] for c in result}
# Every card with qty > 0 and scryfall_id should be here
assert "Sol Ring" in names
assert "Swords to Plow" in names
assert "Counterspell" in names
assert "Damnation" in names
assert "Lightning Bolt" in names
assert "Llanowar Elves" in names
assert "Atraxa" in names
assert "Orzhov Signet" in names
# Excluded: zero qty or missing scryfall_id
assert "Zero-Qty Card" not in names
assert "No-ID Card" not in names
# Test 3: Colourless — only colourless cards
def test_colorless(self):
result = _query_for_colors(self.conn, [])
names = {c["name"] for c in result}
assert "Sol Ring" in names
assert "Plains" in names
assert "Kozilek" in names
# Coloured cards excluded
assert "Swords to Plow" not in names
assert "Counterspell" not in names
assert "Llanowar Elves" not in names
# Test 4: Zero-quantity cards excluded
def test_zero_quantity_excluded(self):
result = _query_for_colors(self.conn, ["W", "U", "B", "R", "G"])
names = {c["name"] for c in result}
assert "Zero-Qty Card" not in names
# Test 5: Malformed color_identity values
def test_malformed_color_identity(self):
conn = _make_conn()
conn.execute("""
INSERT INTO collection_entries (name, color_identity, quantity, scryfall_id, type_line)
VALUES ('Null CI', NULL, 1, 'null-001', 'Artifact')
""")
conn.execute("""
INSERT INTO collection_entries (name, color_identity, quantity, scryfall_id, type_line)
VALUES ('Empty Str CI', '', 1, 'empty-001', 'Artifact')
""")
conn.commit()
# Should not crash — NULL/empty treated as colorless (empty array)
result = _query_for_colors(conn, ["W"])
names = {c["name"] for c in result}
assert "Null CI" in names # NULL COALESCE'd to '[]' -> colorless -> passes
# Empty string '' is not valid JSON but COALESCE handles NULL;
# empty string through json_each may error, check graceful handling
# The COALESCE only guards NULL, '' will go to json_each which may fail
# This tests the edge case
# Test 6: _build_collection_summary returns same filtered count
def test_summary_matches_filter_count(self):
for ci in [["W"], ["W", "U", "B"], ["W", "U", "B", "R", "G"], ["G"]]:
filter_result = _query_for_colors(self.conn, ci)
summary_result = _summary_for_colors(self.conn, ci)
# summary includes cards without scryfall_id, filter excludes them
# So summary count >= filter count; but for our test data only "No-ID Card"
# differs. Compare the intersection.
filter_names = {c["name"] for c in filter_result}
summary_names = {r["name"] for r in summary_result}
# Everything in filter should be in summary (summary is superset minus scryfall_id check)
assert filter_names <= summary_names, (
f"CI={ci}: filter has cards not in summary: {filter_names - summary_names}"
)
# Test: Multi-color subset check (WB commander should include W, B, WB, colorless)
def test_multi_color_subset(self):
result = _query_for_colors(self.conn, ["W", "B"])
names = {c["name"] for c in result}
assert "Sol Ring" in names # colorless
assert "Swords to Plow" in names # W subset of WB
assert "Damnation" in names # B subset of WB
assert "Orzhov Signet" in names # WB subset of WB
assert "Counterspell" not in names # U not in WB
assert "Lightning Bolt" not in names # R not in WB
assert "Atraxa" not in names # WUBG not subset of WB
+169
View File
@@ -465,6 +465,175 @@
box-shadow: var(--shadow-sm);
}
/* ═══════════════════════════════════════════════════════════
Precon Picker
═══════════════════════════════════════════════════════════ */
.lab-modal-content.lab-modal-precon {
max-width: 900px;
width: 94%;
max-height: 82vh;
}
.lab-precon-controls {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-4);
align-items: center;
}
.lab-precon-controls input {
flex: 1;
background: var(--surface-1);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--sp-2) var(--sp-3);
font-size: 13px;
}
.lab-precon-controls input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
.lab-precon-install-all-btn,
.lab-precon-refresh-btn {
background: var(--surface-2);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--sp-2) var(--sp-3);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition: all var(--duration) var(--ease);
}
.lab-precon-install-all-btn:hover,
.lab-precon-refresh-btn:hover {
background: var(--surface-3);
color: var(--text);
border-color: var(--accent);
}
.lab-precon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--sp-3);
max-height: 56vh;
overflow-y: auto;
padding-right: var(--sp-1);
}
.lab-precon-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: all var(--duration) var(--ease);
display: flex;
flex-direction: column;
}
.lab-precon-card:hover {
border-color: var(--accent);
box-shadow: var(--shadow-glow);
transform: translateY(-2px);
}
.lab-precon-card-installed {
border-color: var(--success);
opacity: 0.7;
}
.lab-precon-card-installed:hover {
opacity: 1;
}
.precon-card-art {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
display: block;
background: var(--surface-2);
}
.precon-card-body {
padding: var(--sp-2) var(--sp-3) var(--sp-3);
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
}
.precon-card-colors {
display: flex;
gap: 3px;
margin-bottom: 2px;
}
.precon-card-name {
font-size: 13px;
font-weight: 600;
color: #fff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.precon-card-commander {
font-size: 11px;
color: var(--accent);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.precon-card-set {
font-size: 11px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.precon-card-theme {
font-size: 10px;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lab-precon-install-btn {
margin-top: auto;
padding: var(--sp-1) var(--sp-2);
background: var(--accent-dim);
color: var(--accent);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: all var(--duration) var(--ease);
}
.lab-precon-install-btn:hover {
background: var(--accent);
color: var(--text-inverse);
}
.lab-precon-card-installed .lab-precon-install-btn {
background: var(--success-dim);
color: var(--success);
}
.lab-arch-badge {
font-size: 10px;
padding: 1px 7px;
+2540 -1
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -1256,3 +1256,45 @@
.dg-stat-substituted .dg-stat-num { color: #60a5fa; }
.dg-stat-missing .dg-stat-num { color: #f59e0b; }
.dg-stat-price .dg-stat-num { color: #a78bfa; }
/* -- Conversation Log ---------------------------------------- */
.dg-conversation-log {
margin-top: var(--sp-3);
max-height: 200px;
overflow-y: auto;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--sp-3);
font-family: 'Courier New', monospace;
font-size: 0.8rem;
scrollbar-width: thin;
}
.dg-log-entry {
padding: 3px 0;
line-height: 1.5;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.dg-log-entry:last-child {
border-bottom: none;
}
.dg-log-ts {
color: var(--text-muted);
margin-right: 6px;
}
.dg-log-info {
color: var(--text);
}
.dg-log-success {
color: #60a5fa;
}
.dg-log-error {
color: #f59e0b;
}
+7 -3
View File
@@ -132,8 +132,7 @@
<div class="dg-model-row">
<label class="dg-label">AI Model</label>
<select class="dg-select" id="dg-model">
<option value="sonar">Sonar (fast, ~$0.004)</option>
<option value="sonar-pro">Sonar Pro (deep, ~$0.04)</option>
<option value="gpt-oss:20b" selected>gpt-oss:20b (Local Ollama)</option>
</select>
</div>
</div>
@@ -146,6 +145,11 @@
</button>
</div>
<!-- Conversation Log -->
<div id="dg-conversation-log" class="dg-conversation-log">
<div class="dg-log-entry dg-log-info"><span class="dg-log-ts">[Ready]</span> Waiting for deck generation...</div>
</div>
</section>
<!-- RIGHT: Preview Results -->
@@ -160,7 +164,7 @@
<!-- Loading State -->
<div class="dg-loading" id="dg-loading" style="display:none">
<div class="dg-spinner"></div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Perplexity AI...</div>
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
</div>
<!-- Results -->
+37 -12
View File
@@ -5,7 +5,7 @@
* V3 Endpoints:
* GET /api/deck/v3/status — check V3 generator status
* GET /api/deck-generator/commander-search — autocomplete commanders
* POST /api/deck/v3/generate — generate deck (Perplexity structured output)
* POST /api/deck/v3/generate — generate deck (local AI structured output)
* POST /api/deck/v3/commit — generate + save to Deck Builder
* POST /api/deck/v3/export/csv — export as CSV
* POST /api/deck/v3/export/dck — export as Forge .dck
@@ -68,7 +68,9 @@ const DeckGenerator = (() => {
const data = await resp.json();
state.v3Ready = data.initialized;
if (!state.v3Ready) {
toast('V3 Deck Generator not available — check Perplexity API key', 'error');
// Warn but do not block — local/Ollama mode may still work
console.warn('V3 status: not initialized. Error:', data.error || 'unknown');
toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning');
}
} catch (e) {
console.warn('V3 status check failed:', e);
@@ -223,7 +225,6 @@ const DeckGenerator = (() => {
const img = $('dg-commander-img');
if (cmdr.image_url) {
// Use normal size for readable oracle text; ensure we don't have small version
var imgUrl = cmdr.image_url;
if (imgUrl.indexOf('version=small') !== -1) {
imgUrl = imgUrl.replace('version=small', 'version=normal');
@@ -253,14 +254,39 @@ const DeckGenerator = (() => {
$('dg-commander-dropdown').classList.remove('open');
}
// -- Conversation Log Helper --
function logMessage(msg, type = 'info') {
const log = $('dg-conversation-log');
if (!log) return;
const entry = document.createElement('div');
entry.className = 'dg-log-entry dg-log-' + type;
const ts = new Date().toLocaleTimeString();
entry.innerHTML = '<span class="dg-log-ts">[' + ts + ']</span> ' + msg;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
function clearLog() {
const log = $('dg-conversation-log');
if (!log) return;
log.innerHTML = '';
}
// ── Generate Deck ───────────────────────────────────────
async function generateDeck() {
clearLog();
logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info');
if (!state.commander) { toast('Select a commander first', 'error'); return; }
if (!state.v3Ready) { toast('V3 generator not ready — check Perplexity API key', 'error'); return; }
// Non-blocking warning only — local Ollama mode does not require v3Ready
if (!state.v3Ready) {
logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning');
}
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
setLoading(true, 'Generating deck via Perplexity AI...');
setLoading(true, 'Generating deck via Local AI...');
const body = buildRequestBody();
logMessage('Sending request to AI model... this may take a moment.', 'info');
state.lastRequestBody = body;
try {
@@ -276,12 +302,17 @@ const DeckGenerator = (() => {
}
state.previewResult = await resp.json();
renderResults(state.previewResult);
logMessage('AI response received! Processing deck data...', 'success');
const stats = state.previewResult.stats || {};
const cardCount = (stats.total_cards || 0);
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
renderResults(state.previewResult);
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
} catch (e) {
toast(e.message || 'Generation failed', 'error');
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
setLoading(false);
}
}
@@ -365,7 +396,6 @@ const DeckGenerator = (() => {
const cards = result.cards || [];
const commander = result.commander;
// Group by category
const groups = {};
for (const card of cards) {
const cat = card.category || 'Other';
@@ -380,7 +410,6 @@ const DeckGenerator = (() => {
let html = '';
// Commander section
if (commander) {
html += '<div class="dg-card-group">'
+ '<div class="dg-group-header">'
@@ -395,7 +424,6 @@ const DeckGenerator = (() => {
+ '</div></div>';
}
// Card groups
for (const type of sortedTypes) {
const groupCards = groups[type];
if (!groupCards || groupCards.length === 0) continue;
@@ -419,7 +447,6 @@ const DeckGenerator = (() => {
container.innerHTML = html;
// Collapse toggles
container.querySelectorAll('.dg-group-header').forEach(header => {
header.addEventListener('click', () => {
const type = header.dataset.type;
@@ -438,7 +465,6 @@ const DeckGenerator = (() => {
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
.join('');
// Substitution indicator
let subHtml = '';
if (card.status === 'substituted' && card.selected_substitute) {
subHtml = '<div class="dg-card-sub-line">'
@@ -577,7 +603,6 @@ const DeckGenerator = (() => {
return;
}
// Build and copy text
const lines = ['Shopping List — ' + (data.commander || 'Deck'), ''];
for (const item of list) {
lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category);
+2 -2
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="global.css" />
<link rel="stylesheet" href="ai-lab.css" />
<link rel="stylesheet" href="ai-lab.css?v=4" />
<style>
/* ── Dashboard Feature Grid ──────────────────── */
.feature-grid {
@@ -196,6 +196,6 @@
</div>
</div>
<script src="ai-lab.js"></script>
<script src="ai-lab.js?v=4"></script>
</body>
</html>
+1 -1
View File
@@ -62,7 +62,7 @@
<div class="deepseek-config-row">
<div class="form-group sim-field-grow">
<label class="form-label" for="ds-api-base">API Endpoint</label>
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.122:1234" placeholder="http://192.168.0.122:1234" />
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.240:11434" placeholder="http://192.168.0.240:11434" />
</div>
<div class="form-group sim-field-auto">
<label class="form-label" for="ds-model">Model</label>
+56
View File
@@ -0,0 +1,56 @@
# Unity generated
/[Ll]ibrary/
/[Tt]emp/
/[Oo]bj/
/[Bb]uild/
/[Bb]uilds/
/[Ll]ogs/
/[Uu]ser[Ss]ettings/
# MemoryCaptures
/[Mm]emoryCaptures/
# Asset meta data should only be ignored when the corresponding asset is also ignored
!/[Aa]ssets/**/*.meta
# Autogenerated Coverage
/[Aa]ssets/[Cc]overage[Rr]esults/
# Visual Studio
.vs/
*.csproj
*.sln
*.suo
*.tmp
*.user
*.userprefs
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
# Gradle cache (Android)
.gradle/
# TextMesh Pro
/[Aa]ssets/TextMesh Pro/
# Rider
.idea/
# Builds
*.apk
*.aab
*.unitypackage
*.app
# Crashlytics
crashlytics-build.properties
# Addressables
/[Aa]ssets/[Aa]ddressable[Aa]ssets[Dd]ata/*/*.bin*
/[Aa]ssets/[Ss]treamingAssets/aa.meta
/[Aa]ssets/[Ss]treamingAssets/aa/*
@@ -0,0 +1,32 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class BuildAllScenes
{
[MenuItem("Tools/Build All Scenes")]
public static void BuildAll()
{
// Clear build settings first
EditorBuildSettings.scenes = new EditorBuildSettingsScene[0];
// Create all scenes in order (MainMenu must be index 0)
CreateMainMenuScene.Create();
CreateCollectionScene.Create();
CreateDeckBuilderScene.Create();
CreateSimulatorScene.Create();
CreateScannerScene.Create();
CreateTrainingScene.Create();
// Log the final build settings
Debug.Log($"[BuildAllScenes] Registered {EditorBuildSettings.scenes.Length} scenes in Build Settings:");
foreach (var s in EditorBuildSettings.scenes)
Debug.Log($" {s.path} (enabled={s.enabled})");
// Reopen MainMenu as the active scene
EditorSceneManager.OpenScene("Assets/Scenes/MainMenu.unity");
Debug.Log("[BuildAllScenes] Done! MainMenu scene is now active.");
}
}
#endif
@@ -0,0 +1,354 @@
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using UnityEditor.SceneManagement;
using TMPro;
using System.Reflection;
using System.Linq;
public static class CreateCollectionScene
{
[MenuItem("Tools/Create Collection Scene")]
public static void Create()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
// -- Services --
var services = new GameObject("[Services]");
services.AddComponent<CommanderAILab.Services.ApiClient>();
services.AddComponent<CommanderAILab.Services.ImageCache>();
// -- Canvas --
var canvasGo = new GameObject("Canvas");
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
// -- EventSystem --
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
// -- Background --
var bg = CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true);
// -- Header bar --
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f, 0.95f));
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.07f, 0.9f));
var titleText = CreateTMPText(header.transform, "TitleText", "Collection", 28, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(titleText.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
// -- Search bar --
var searchBar = CreatePanel(canvasGo.transform, "SearchBar", new Color(0.1f, 0.1f, 0.16f, 0.98f));
SetAnchors(searchBar.GetComponent<RectTransform>(), new Vector2(0.18f, 0.86f), new Vector2(1f, 0.93f));
var searchInput = CreateTMPInputField(searchBar.transform, "SearchInput", "Search cards...");
SetAnchors(searchInput.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.72f, 0.9f));
var searchBtn = CreateTMPButton(searchBar.transform, "SearchButton", "Search", new Color(0.2f, 0.5f, 0.8f));
SetAnchors(searchBtn.GetComponent<RectTransform>(), new Vector2(0.73f, 0.1f), new Vector2(0.86f, 0.9f));
var sortDd = CreateTMPDropdown(searchBar.transform, "SortDropdown",
new[] { "Name", "CMC", "Color", "Type", "Price" });
SetAnchors(sortDd.GetComponent<RectTransform>(), new Vector2(0.87f, 0.05f), new Vector2(0.99f, 0.95f));
// -- Filter toggle button (mobile) --
var filterToggleBtn = CreateTMPButton(canvasGo.transform, "FilterToggleButton", "Filters", new Color(0.18f, 0.22f, 0.35f));
SetAnchors(filterToggleBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.86f), new Vector2(0.17f, 0.93f));
// -- Filter sidebar --
var filterPanel = CreatePanel(canvasGo.transform, "FilterPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(filterPanel.GetComponent<RectTransform>(), new Vector2(0f, 0.06f), new Vector2(0.18f, 0.86f));
var filterVlg = filterPanel.AddComponent<VerticalLayoutGroup>();
filterVlg.padding = new RectOffset(8, 8, 8, 8);
filterVlg.spacing = 6;
filterVlg.childForceExpandWidth = true;
filterVlg.childForceExpandHeight = false;
CreateTMPText(filterPanel.transform, "FilterTitle", "Filters", 18, FontStyles.Bold, TextAlignmentOptions.Center);
var colorDd = CreateTMPDropdown(filterPanel.transform, "ColorFilterDropdown", new[] { "All Colors", "W", "U", "B", "R", "G", "Colorless" });
var typeDd = CreateTMPDropdown(filterPanel.transform, "TypeFilterDropdown", new[] { "All Types", "Creature", "Instant", "Sorcery", "Enchantment", "Artifact", "Planeswalker", "Land" });
var setDd = CreateTMPDropdown(filterPanel.transform, "SetFilterDropdown", new[] { "All Sets" });
var rarityDd = CreateTMPDropdown(filterPanel.transform, "RarityFilterDropdown", new[] { "All Rarities", "Common", "Uncommon", "Rare", "Mythic" });
var ownedDd = CreateTMPDropdown(filterPanel.transform, "OwnedFilterDropdown", new[] { "All", "Owned", "Missing" });
var applyBtn = CreateTMPButton(filterPanel.transform, "ApplyFilterButton", "Apply", new Color(0.2f, 0.55f, 0.2f));
var clearBtn = CreateTMPButton(filterPanel.transform, "ClearFilterButton", "Clear", new Color(0.45f, 0.2f, 0.2f));
// -- Card grid scroll view --
var scrollGo = new GameObject("CardGridScroll");
scrollGo.transform.SetParent(canvasGo.transform, false);
var scrollRect = scrollGo.AddComponent<ScrollRect>();
var scrollImg = scrollGo.AddComponent<Image>();
scrollImg.color = new Color(0f, 0f, 0f, 0.01f);
SetAnchors(scrollGo.GetComponent<RectTransform>(), new Vector2(0.18f, 0.12f), new Vector2(1f, 0.86f));
var content = new GameObject("Content");
content.transform.SetParent(scrollGo.transform, false);
var contentRect = content.AddComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var grid = content.AddComponent<GridLayoutGroup>();
grid.cellSize = new Vector2(160, 220);
grid.spacing = new Vector2(8, 8);
grid.padding = new RectOffset(10, 10, 10, 10);
grid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
grid.constraintCount = 6;
scrollRect.content = contentRect;
scrollRect.horizontal = false;
// -- Page info & load more --
var pageInfoText = CreateTMPText(canvasGo.transform, "PageInfoText", "Showing 0 of 0 cards", 16, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(pageInfoText.GetComponent<RectTransform>(), new Vector2(0.18f, 0.08f), new Vector2(0.6f, 0.12f));
var loadMoreBtn = CreateTMPButton(canvasGo.transform, "LoadMoreButton", "Load More", new Color(0.2f, 0.4f, 0.6f));
SetAnchors(loadMoreBtn.GetComponent<RectTransform>(), new Vector2(0.62f, 0.08f), new Vector2(0.82f, 0.12f));
// -- Loading spinner (simple panel) --
var spinner = CreatePanel(canvasGo.transform, "LoadingSpinner", new Color(0f, 0f, 0f, 0.6f), stretch: true);
CreateTMPText(spinner.transform, "SpinnerText", "Loading...", 32, FontStyles.Bold, TextAlignmentOptions.Center);
spinner.SetActive(false);
// -- Detail panel --
var detailPanel = CreatePanel(canvasGo.transform, "DetailPanel", new Color(0.08f, 0.08f, 0.14f, 0.97f), stretch: true);
var detailCardFront = new GameObject("DetailCardFront");
detailCardFront.transform.SetParent(detailPanel.transform, false);
var detailFrontImg = detailCardFront.AddComponent<Image>();
SetAnchors(detailCardFront.GetComponent<RectTransform>(), new Vector2(0.05f, 0.15f), new Vector2(0.4f, 0.92f));
var detailCardBack = new GameObject("DetailCardBack");
detailCardBack.transform.SetParent(detailPanel.transform, false);
detailCardBack.AddComponent<Image>().color = new Color(0.1f, 0.1f, 0.2f);
SetAnchors(detailCardBack.GetComponent<RectTransform>(), new Vector2(0.05f, 0.15f), new Vector2(0.4f, 0.92f));
var detailName = CreateTMPText(detailPanel.transform, "DetailCardName", "", 28, FontStyles.Bold, TextAlignmentOptions.Left);
SetAnchors(detailName.GetComponent<RectTransform>(), new Vector2(0.42f, 0.78f), new Vector2(0.95f, 0.92f));
var detailMana = CreateTMPText(detailPanel.transform, "DetailManaCost", "", 22, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(detailMana.GetComponent<RectTransform>(), new Vector2(0.42f, 0.70f), new Vector2(0.95f, 0.78f));
var detailType = CreateTMPText(detailPanel.transform, "DetailType", "", 20, FontStyles.Italic, TextAlignmentOptions.Left);
SetAnchors(detailType.GetComponent<RectTransform>(), new Vector2(0.42f, 0.63f), new Vector2(0.95f, 0.70f));
var detailRarity = CreateTMPText(detailPanel.transform, "DetailRarity", "", 18, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(detailRarity.GetComponent<RectTransform>(), new Vector2(0.42f, 0.57f), new Vector2(0.95f, 0.63f));
var detailSet = CreateTMPText(detailPanel.transform, "DetailSet", "", 18, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(detailSet.GetComponent<RectTransform>(), new Vector2(0.42f, 0.51f), new Vector2(0.95f, 0.57f));
var detailOracle = CreateTMPText(detailPanel.transform, "DetailOracleText", "", 17, FontStyles.Normal, TextAlignmentOptions.TopLeft);
SetAnchors(detailOracle.GetComponent<RectTransform>(), new Vector2(0.42f, 0.28f), new Vector2(0.95f, 0.51f));
var detailQty = CreateTMPText(detailPanel.transform, "DetailQuantity", "", 18, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(detailQty.GetComponent<RectTransform>(), new Vector2(0.42f, 0.22f), new Vector2(0.95f, 0.28f));
var addToDeckBtn = CreateTMPButton(detailPanel.transform, "DetailAddToDeckButton", "Add to Deck", new Color(0.2f, 0.55f, 0.2f));
SetAnchors(addToDeckBtn.GetComponent<RectTransform>(), new Vector2(0.42f, 0.12f), new Vector2(0.68f, 0.20f));
var closeDetailBtn = CreateTMPButton(detailPanel.transform, "DetailCloseButton", "Close", new Color(0.5f, 0.15f, 0.15f));
SetAnchors(closeDetailBtn.GetComponent<RectTransform>(), new Vector2(0.70f, 0.12f), new Vector2(0.95f, 0.20f));
detailPanel.SetActive(false);
// -- Error panel --
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.5f, 0.1f, 0.1f, 0.95f));
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.2f, 0.02f), new Vector2(0.8f, 0.10f));
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.Center);
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0f, 0.2f), new Vector2(0.75f, 1f));
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.6f, 0.25f, 0.25f));
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.78f, 0.2f), new Vector2(0.98f, 0.8f));
errorPanel.SetActive(false);
// -- Wire controller --
var ctrl = canvasGo.AddComponent<CommanderAILab.UI.CollectionController>();
SetField(ctrl, "backButton", backBtn.GetComponent<Button>());
SetField(ctrl, "searchInput", searchInput.GetComponent<TMP_InputField>());
SetField(ctrl, "sortDropdown", sortDd.GetComponent<TMP_Dropdown>());
SetField(ctrl, "searchButton", searchBtn.GetComponent<Button>());
SetField(ctrl, "filterToggleButton", filterToggleBtn.GetComponent<Button>());
SetField(ctrl, "filterPanel", filterPanel);
SetField(ctrl, "colorFilterDropdown", colorDd.GetComponent<TMP_Dropdown>());
SetField(ctrl, "typeFilterDropdown", typeDd.GetComponent<TMP_Dropdown>());
SetField(ctrl, "setFilterDropdown", setDd.GetComponent<TMP_Dropdown>());
SetField(ctrl, "rarityFilterDropdown", rarityDd.GetComponent<TMP_Dropdown>());
SetField(ctrl, "ownedFilterDropdown", ownedDd.GetComponent<TMP_Dropdown>());
SetField(ctrl, "applyFilterButton", applyBtn.GetComponent<Button>());
SetField(ctrl, "clearFilterButton", clearBtn.GetComponent<Button>());
SetField(ctrl, "cardGridParent", content.transform);
SetField(ctrl, "cardPrefab", null); // assign in Inspector
SetField(ctrl, "gridScrollRect", scrollGo.GetComponent<ScrollRect>());
SetField(ctrl, "loadMoreButton", loadMoreBtn.GetComponent<Button>());
SetField(ctrl, "pageInfoText", pageInfoText.GetComponent<TMP_Text>());
SetField(ctrl, "loadingSpinner", spinner);
SetField(ctrl, "detailPanel", detailPanel);
SetField(ctrl, "detailCardFront", detailCardFront.GetComponent<Image>());
SetField(ctrl, "detailCardBack", detailCardBack.GetComponent<Image>());
SetField(ctrl, "detailCardName", detailName.GetComponent<TMP_Text>());
SetField(ctrl, "detailManaCost", detailMana.GetComponent<TMP_Text>());
SetField(ctrl, "detailType", detailType.GetComponent<TMP_Text>());
SetField(ctrl, "detailRarity", detailRarity.GetComponent<TMP_Text>());
SetField(ctrl, "detailSet", detailSet.GetComponent<TMP_Text>());
SetField(ctrl, "detailOracleText", detailOracle.GetComponent<TMP_Text>());
SetField(ctrl, "detailQuantity", detailQty.GetComponent<TMP_Text>());
SetField(ctrl, "detailAddToDeckButton", addToDeckBtn.GetComponent<Button>());
SetField(ctrl, "detailCloseButton", closeDetailBtn.GetComponent<Button>());
SetField(ctrl, "errorPanel", errorPanel);
SetField(ctrl, "errorText", errorText.GetComponent<TMP_Text>());
SetField(ctrl, "retryButton", retryBtn.GetComponent<Button>());
// -- Save --
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
AssetDatabase.CreateFolder("Assets", "Scenes");
string path = "Assets/Scenes/Collection.unity";
EditorSceneManager.SaveScene(scene, path);
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
if (!existing.Any(s => s.path == path))
existing.Add(new EditorBuildSettingsScene(path, true));
EditorBuildSettings.scenes = existing.ToArray();
Debug.Log("[CreateCollectionScene] Collection scene created and saved to " + path);
}
// -- Helpers --
static void SetField(object target, string fieldName, object value)
{
var field = target.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null) field.SetValue(target, value);
else Debug.LogWarning("Could not find field: " + fieldName);
}
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
{
rt.anchorMin = min;
rt.anchorMax = max;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = color;
var rt = go.GetComponent<RectTransform>();
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.fontStyle = style;
tmp.alignment = alignment;
tmp.color = Color.white;
var rt = go.GetComponent<RectTransform>();
SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = bgColor;
var btn = go.AddComponent<Button>();
btn.targetGraphic = img;
var colors = btn.colors;
colors.highlightedColor = bgColor * 1.2f;
colors.pressedColor = bgColor * 0.8f;
btn.colors = colors;
var textGo = new GameObject("Text");
textGo.transform.SetParent(go.transform, false);
var tmp = textGo.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 22;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var le = go.AddComponent<LayoutElement>();
le.minHeight = 50;
return go;
}
static GameObject CreateTMPDropdown(Transform parent, string name, string[] options)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
var labelGo = new GameObject("Label");
labelGo.transform.SetParent(go.transform, false);
var labelTmp = labelGo.AddComponent<TextMeshProUGUI>();
labelTmp.text = options.Length > 0 ? options[0] : "";
labelTmp.fontSize = 16;
labelTmp.alignment = TextAlignmentOptions.Left;
labelTmp.color = Color.white;
SetAnchors(labelGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var dd = go.AddComponent<TMP_Dropdown>();
dd.captionText = labelTmp;
dd.ClearOptions();
dd.AddOptions(new System.Collections.Generic.List<string>(options));
var le = go.AddComponent<LayoutElement>();
le.minHeight = 40;
return go;
}
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
var textArea = new GameObject("Text Area");
textArea.transform.SetParent(go.transform, false);
textArea.AddComponent<RectMask2D>();
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var placeholder = new GameObject("Placeholder");
placeholder.transform.SetParent(textArea.transform, false);
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
phTmp.text = defaultText;
phTmp.fontSize = 18;
phTmp.fontStyle = FontStyles.Italic;
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
phTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputText = new GameObject("Text");
inputText.transform.SetParent(textArea.transform, false);
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
itTmp.fontSize = 18;
itTmp.color = Color.white;
itTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputField = go.AddComponent<TMP_InputField>();
inputField.textViewport = textArea.GetComponent<RectTransform>();
inputField.textComponent = itTmp;
inputField.placeholder = phTmp;
inputField.text = defaultText;
inputField.fontAsset = itTmp.font;
return go;
}
}
#endif
@@ -0,0 +1,532 @@
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using UnityEditor.SceneManagement;
using TMPro;
using System.Reflection;
using System.Linq;
public static class CreateDeckBuilderScene
{
[MenuItem("Tools/Create DeckBuilder Scene")]
public static void Create()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
// Services
var services = new GameObject("[Services]");
services.AddComponent<CommanderAILab.Services.ApiClient>();
services.AddComponent<CommanderAILab.Services.ImageCache>();
// Canvas
var canvasGo = new GameObject("Canvas");
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
// Background
var bg = CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true);
// Header
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f, 0.95f));
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.07f, 0.9f));
var titleTxt = CreateTMPText(header.transform, "TitleText", "Deck Builder", 28, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(titleTxt.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
var exportBtn = CreateTMPButton(header.transform, "ExportToSimButton", "Export to Sim", new Color(0.2f, 0.45f, 0.7f));
SetAnchors(exportBtn.GetComponent<RectTransform>(), new Vector2(0.88f, 0.1f), new Vector2(0.99f, 0.9f));
// ── LEFT: Deck List Panel ──────────────────────────────────────
var leftPanel = CreatePanel(canvasGo.transform, "DeckListPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(leftPanel.GetComponent<RectTransform>(), new Vector2(0f, 0.06f), new Vector2(0.22f, 0.93f));
var deckNameInput = CreateTMPInputField(leftPanel.transform, "DeckNameInput", "New deck name...");
SetAnchors(deckNameInput.GetComponent<RectTransform>(), new Vector2(0.02f, 0.91f), new Vector2(0.75f, 0.98f));
var newDeckBtn = CreateTMPButton(leftPanel.transform, "NewDeckButton", "+", new Color(0.2f, 0.55f, 0.2f));
SetAnchors(newDeckBtn.GetComponent<RectTransform>(), new Vector2(0.77f, 0.91f), new Vector2(0.98f, 0.98f));
var deckScroll = new GameObject("DeckListScroll");
deckScroll.transform.SetParent(leftPanel.transform, false);
var dsr = deckScroll.AddComponent<ScrollRect>();
deckScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
SetAnchors(deckScroll.GetComponent<RectTransform>(), new Vector2(0f, 0f), new Vector2(1f, 0.9f));
var deckContent = new GameObject("Content");
deckContent.transform.SetParent(deckScroll.transform, false);
var dcrect = deckContent.AddComponent<RectTransform>();
dcrect.anchorMin = new Vector2(0,1); dcrect.anchorMax = new Vector2(1,1); dcrect.pivot = new Vector2(0.5f,1f);
deckContent.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var dcvlg = deckContent.AddComponent<VerticalLayoutGroup>();
dcvlg.spacing = 4; dcvlg.padding = new RectOffset(4,4,4,4);
dcvlg.childForceExpandWidth = true; dcvlg.childForceExpandHeight = false;
dsr.content = dcrect; dsr.horizontal = false;
// ── CENTER: Active Deck Panel ──────────────────────────────────
var centerPanel = CreatePanel(canvasGo.transform, "ActiveDeckPanel", new Color(0.09f, 0.09f, 0.14f, 0.97f));
SetAnchors(centerPanel.GetComponent<RectTransform>(), new Vector2(0.22f, 0.06f), new Vector2(0.58f, 0.93f));
var deckTitleTxt = CreateTMPText(centerPanel.transform, "DeckTitleText", "Select a Deck", 22, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(deckTitleTxt.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(0.7f, 1f));
var cardCountTxt = CreateTMPText(centerPanel.transform, "CardCountText", "0 / 100", 18, FontStyles.Normal, TextAlignmentOptions.Right);
SetAnchors(cardCountTxt.GetComponent<RectTransform>(), new Vector2(0.7f, 0.93f), new Vector2(1f, 1f));
var validIndic = CreatePanel(centerPanel.transform, "ValidationIndicator", new Color(0.7f, 0.2f, 0.2f));
SetAnchors(validIndic.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(0.12f, 0.93f));
var validTxt = CreateTMPText(centerPanel.transform, "ValidationText", "100 cards needed", 14, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(validTxt.GetComponent<RectTransform>(), new Vector2(0.13f, 0.88f), new Vector2(1f, 0.93f));
// Commander
var cmdInput = CreateTMPInputField(centerPanel.transform, "CommanderSearchInput", "Search commander...");
SetAnchors(cmdInput.GetComponent<RectTransform>(), new Vector2(0.01f, 0.81f), new Vector2(0.65f, 0.87f));
var cmdSearchBtn = CreateTMPButton(centerPanel.transform, "CommanderSearchButton", "Find", new Color(0.25f, 0.45f, 0.65f));
SetAnchors(cmdSearchBtn.GetComponent<RectTransform>(), new Vector2(0.66f, 0.81f), new Vector2(0.82f, 0.87f));
var colorIdDd = CreateTMPDropdown(centerPanel.transform, "ColorIdentityDropdown", new[]{"Any","W","U","B","R","G","WU","WB","WR","WG","UB","UR","UG","BR","BG","RG"});
SetAnchors(colorIdDd.GetComponent<RectTransform>(), new Vector2(0.01f, 0.75f), new Vector2(0.98f, 0.81f));
var cmdResultScroll = new GameObject("CommanderResultScroll");
cmdResultScroll.transform.SetParent(centerPanel.transform, false);
var cmrsr = cmdResultScroll.AddComponent<ScrollRect>();
cmdResultScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
SetAnchors(cmdResultScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.66f), new Vector2(1f, 0.75f));
var cmdContent = CreateScrollContent(cmdResultScroll, out cmrsr);
var selCmdTxt = CreateTMPText(centerPanel.transform, "SelectedCommanderText", "Commander: None", 15, FontStyles.Italic, TextAlignmentOptions.Left);
SetAnchors(selCmdTxt.GetComponent<RectTransform>(), new Vector2(0.01f, 0.62f), new Vector2(0.98f, 0.66f));
// Deck card list
var deckCardScroll = new GameObject("DeckCardListScroll");
deckCardScroll.transform.SetParent(centerPanel.transform, false);
SetAnchors(deckCardScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.12f), new Vector2(1f, 0.62f));
var dclsr = deckCardScroll.AddComponent<ScrollRect>();
deckCardScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
var deckCardContent = CreateScrollContent(deckCardScroll, out dclsr);
var saveDeckBtn = CreateTMPButton(centerPanel.transform, "SaveDeckButton", "Save", new Color(0.2f, 0.55f, 0.2f));
SetAnchors(saveDeckBtn.GetComponent<RectTransform>(), new Vector2(0.02f, 0.03f), new Vector2(0.35f, 0.11f));
var deleteDeckBtn = CreateTMPButton(centerPanel.transform, "DeleteDeckButton", "Delete Deck", new Color(0.55f, 0.15f, 0.15f));
SetAnchors(deleteDeckBtn.GetComponent<RectTransform>(), new Vector2(0.37f, 0.03f), new Vector2(0.7f, 0.11f));
// ── RIGHT: Card Search + Charts ────────────────────────────────
var rightPanel = CreatePanel(canvasGo.transform, "CardSearchPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(rightPanel.GetComponent<RectTransform>(), new Vector2(0.58f, 0.06f), new Vector2(1f, 0.93f));
var cardSearchInput = CreateTMPInputField(rightPanel.transform, "CardSearchInput", "Search cards...");
SetAnchors(cardSearchInput.GetComponent<RectTransform>(), new Vector2(0.01f, 0.92f), new Vector2(0.72f, 0.99f));
var cardSearchBtn = CreateTMPButton(rightPanel.transform, "CardSearchButton", "Search", new Color(0.2f, 0.5f, 0.8f));
SetAnchors(cardSearchBtn.GetComponent<RectTransform>(), new Vector2(0.73f, 0.92f), new Vector2(0.99f, 0.99f));
var cardResultScroll = new GameObject("CardSearchResultScroll");
cardResultScroll.transform.SetParent(rightPanel.transform, false);
SetAnchors(cardResultScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.55f), new Vector2(1f, 0.92f));
var crsr = cardResultScroll.AddComponent<ScrollRect>();
cardResultScroll.AddComponent<Image>().color = new Color(0,0,0,0.01f);
var cardResultContent = CreateScrollContent(cardResultScroll, out crsr);
// Mana curve
var manaCurveLabel = CreateTMPText(rightPanel.transform, "ManaCurveLabel", "Mana Curve", 16, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(manaCurveLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.49f), new Vector2(1f, 0.54f));
var manaCurveParent = new GameObject("ManaCurveBarParent");
manaCurveParent.transform.SetParent(rightPanel.transform, false);
SetAnchors(manaCurveParent.GetComponent<RectTransform>(), new Vector2(0.02f, 0.33f), new Vector2(0.98f, 0.49f));
var manaHlg = manaCurveParent.AddComponent<HorizontalLayoutGroup>();
manaHlg.spacing = 4; manaHlg.childForceExpandWidth = true; manaHlg.childForceExpandHeight = false;
// Color pie
var colorPieLabel = CreateTMPText(rightPanel.transform, "ColorPieLabel", "Color Identity", 16, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(colorPieLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.27f), new Vector2(1f, 0.32f));
var colorPieParent = new GameObject("ColorPieParent");
colorPieParent.transform.SetParent(rightPanel.transform, false);
SetAnchors(colorPieParent.GetComponent<RectTransform>(), new Vector2(0.02f, 0.13f), new Vector2(0.98f, 0.27f));
var pieHlg = colorPieParent.AddComponent<HorizontalLayoutGroup>();
pieHlg.spacing = 6; pieHlg.childForceExpandWidth = true; pieHlg.childForceExpandHeight = false;
Color[] pieColors = { Color.white, new Color(0.3f,0.6f,1f), new Color(0.1f,0.1f,0.1f), new Color(0.8f,0.2f,0.2f), new Color(0.2f,0.7f,0.2f) };
string[] pieLabels = { "W", "U", "B", "R", "G" };
var pieSliceImages = new Image[pieColors.Length];
for (int i = 0; i < pieColors.Length; i++)
{
var slice = new GameObject("Slice_" + pieLabels[i]);
slice.transform.SetParent(colorPieParent.transform, false);
var sliceImg = slice.AddComponent<Image>();
sliceImg.color = pieColors[i];
var sliceText = CreateTMPText(slice.transform, "Label", pieLabels[i], 14, FontStyles.Bold, TextAlignmentOptions.Center);
pieSliceImages[i] = sliceImg;
}
// -- Card type breakdown --
var typeLabel = CreateTMPText(rightPanel.transform, "TypeBreakdownLabel", "Card Types", 16, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(typeLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.08f), new Vector2(1f, 0.13f));
var typeText = CreateTMPText(rightPanel.transform, "TypeBreakdownText", "", 14, FontStyles.Normal, TextAlignmentOptions.TopLeft);
SetAnchors(typeText.GetComponent<RectTransform>(), new Vector2(0.05f, 0.01f), new Vector2(0.95f, 0.08f));
// ── Error Panel (hidden initially) ────────────────────────────────────
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.15f, 0.05f, 0.05f, 0.97f));
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.25f, 0.35f), new Vector2(0.75f, 0.65f));
errorPanel.SetActive(false);
CreateTMPText(errorPanel.transform, "ErrorTitle", "Error", 22, FontStyles.Bold, TextAlignmentOptions.Center);
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0.05f, 0.25f), new Vector2(0.95f, 0.85f));
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.55f, 0.2f, 0.2f));
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.3f, 0.05f), new Vector2(0.7f, 0.22f));
// ── Prefabs (disabled GameObjects parented to canvas, used as prefabs) ──
// deckRowPrefab: horizontal layout with TMP_Text for deck name and a Button
var deckRowPrefab = new GameObject("DeckRowPrefab");
deckRowPrefab.transform.SetParent(canvasGo.transform, false);
deckRowPrefab.SetActive(false);
deckRowPrefab.AddComponent<Image>().color = new Color(0.15f, 0.15f, 0.22f, 0.9f);
var drkHlg = deckRowPrefab.AddComponent<HorizontalLayoutGroup>();
drkHlg.padding = new RectOffset(8, 8, 4, 4);
drkHlg.spacing = 8;
drkHlg.childForceExpandWidth = true;
drkHlg.childForceExpandHeight = true;
deckRowPrefab.AddComponent<LayoutElement>().minHeight = 40;
var drkNameCell = new GameObject("DeckName");
drkNameCell.transform.SetParent(deckRowPrefab.transform, false);
var drkNameTxt = drkNameCell.AddComponent<TextMeshProUGUI>();
drkNameTxt.text = "Deck"; drkNameTxt.fontSize = 16; drkNameTxt.color = Color.white;
var drkBtn = new GameObject("SelectButton");
drkBtn.transform.SetParent(deckRowPrefab.transform, false);
drkBtn.AddComponent<Image>().color = new Color(0.2f, 0.4f, 0.7f);
drkBtn.AddComponent<Button>();
drkBtn.AddComponent<LayoutElement>().minWidth = 60;
// deckCardRowPrefab: horizontal layout with TMP_Text for card name, TMP_Text for quantity, and a remove Button
var deckCardRowPrefab = new GameObject("DeckCardRowPrefab");
deckCardRowPrefab.transform.SetParent(canvasGo.transform, false);
deckCardRowPrefab.SetActive(false);
deckCardRowPrefab.AddComponent<Image>().color = new Color(0.13f, 0.13f, 0.2f, 0.9f);
var dcrHlg = deckCardRowPrefab.AddComponent<HorizontalLayoutGroup>();
dcrHlg.padding = new RectOffset(6, 6, 3, 3);
dcrHlg.spacing = 8;
dcrHlg.childForceExpandWidth = true;
dcrHlg.childForceExpandHeight = true;
deckCardRowPrefab.AddComponent<LayoutElement>().minHeight = 36;
var dcrNameCell = new GameObject("CardName");
dcrNameCell.transform.SetParent(deckCardRowPrefab.transform, false);
var dcrNameTxt = dcrNameCell.AddComponent<TextMeshProUGUI>();
dcrNameTxt.text = "Card"; dcrNameTxt.fontSize = 15; dcrNameTxt.color = Color.white;
dcrNameCell.AddComponent<LayoutElement>().flexibleWidth = 1f;
var dcrQtyCell = new GameObject("ManaCost");
dcrQtyCell.transform.SetParent(deckCardRowPrefab.transform, false);
var dcrQtyTxt = dcrQtyCell.AddComponent<TextMeshProUGUI>();
dcrQtyTxt.text = "{3}"; dcrQtyTxt.fontSize = 14; dcrQtyTxt.color = new Color(0.8f, 0.8f, 0.5f);
dcrQtyCell.AddComponent<LayoutElement>().minWidth = 50;
var dcrRemCell = new GameObject("RemoveButton");
dcrRemCell.transform.SetParent(deckCardRowPrefab.transform, false);
dcrRemCell.AddComponent<Image>().color = new Color(0.6f, 0.15f, 0.15f);
dcrRemCell.AddComponent<Button>();
dcrRemCell.AddComponent<LayoutElement>().minWidth = 40;
// commanderResultRowPrefab: horizontal layout with TMP_Text for commander name and a select Button
var commanderResultRowPrefab = new GameObject("CommanderResultRowPrefab");
commanderResultRowPrefab.transform.SetParent(canvasGo.transform, false);
commanderResultRowPrefab.SetActive(false);
commanderResultRowPrefab.AddComponent<Image>().color = new Color(0.15f, 0.18f, 0.25f, 0.9f);
var crrHlg = commanderResultRowPrefab.AddComponent<HorizontalLayoutGroup>();
crrHlg.padding = new RectOffset(8, 8, 4, 4);
crrHlg.spacing = 8;
crrHlg.childForceExpandWidth = true;
crrHlg.childForceExpandHeight = true;
commanderResultRowPrefab.AddComponent<LayoutElement>().minHeight = 36;
var crrNameCell = new GameObject("CommanderName");
crrNameCell.transform.SetParent(commanderResultRowPrefab.transform, false);
var crrNameTxt = crrNameCell.AddComponent<TextMeshProUGUI>();
crrNameTxt.text = "Commander"; crrNameTxt.fontSize = 15; crrNameTxt.color = Color.white;
crrNameCell.AddComponent<LayoutElement>().flexibleWidth = 1f;
var crrBtn = new GameObject("SelectButton");
crrBtn.transform.SetParent(commanderResultRowPrefab.transform, false);
crrBtn.AddComponent<Image>().color = new Color(0.25f, 0.45f, 0.65f);
crrBtn.AddComponent<Button>();
crrBtn.AddComponent<LayoutElement>().minWidth = 60;
// cardSearchRowPrefab: horizontal layout with TMP_Text for card name and an Add Button
var cardSearchRowPrefab = new GameObject("CardSearchRowPrefab");
cardSearchRowPrefab.transform.SetParent(canvasGo.transform, false);
cardSearchRowPrefab.SetActive(false);
cardSearchRowPrefab.AddComponent<Image>().color = new Color(0.12f, 0.15f, 0.2f, 0.9f);
var csrHlg = cardSearchRowPrefab.AddComponent<HorizontalLayoutGroup>();
csrHlg.padding = new RectOffset(8, 8, 4, 4);
csrHlg.spacing = 8;
csrHlg.childForceExpandWidth = true;
csrHlg.childForceExpandHeight = true;
cardSearchRowPrefab.AddComponent<LayoutElement>().minHeight = 36;
var csrNameCell = new GameObject("CardName");
csrNameCell.transform.SetParent(cardSearchRowPrefab.transform, false);
var csrNameTxt = csrNameCell.AddComponent<TextMeshProUGUI>();
csrNameTxt.text = "Card"; csrNameTxt.fontSize = 15; csrNameTxt.color = Color.white;
csrNameCell.AddComponent<LayoutElement>().flexibleWidth = 1f;
var csrAddBtn = new GameObject("AddButton");
csrAddBtn.transform.SetParent(cardSearchRowPrefab.transform, false);
csrAddBtn.AddComponent<Image>().color = new Color(0.2f, 0.55f, 0.2f);
csrAddBtn.AddComponent<Button>();
csrAddBtn.AddComponent<LayoutElement>().minWidth = 50;
// manaCurveBarPrefab: a simple Image (bar) with a TMP_Text label
var manaCurveBarPrefab = new GameObject("ManaCurveBarPrefab");
manaCurveBarPrefab.transform.SetParent(canvasGo.transform, false);
manaCurveBarPrefab.SetActive(false);
manaCurveBarPrefab.AddComponent<Image>().color = new Color(0.3f, 0.5f, 0.9f);
var mcbLe = manaCurveBarPrefab.AddComponent<LayoutElement>();
mcbLe.minWidth = 20; mcbLe.flexibleWidth = 1f;
var mcbLabel = new GameObject("Label");
mcbLabel.transform.SetParent(manaCurveBarPrefab.transform, false);
var mcbLabelTxt = mcbLabel.AddComponent<TextMeshProUGUI>();
mcbLabelTxt.text = "0"; mcbLabelTxt.fontSize = 12; mcbLabelTxt.color = Color.white;
mcbLabelTxt.alignment = TextAlignmentOptions.Center;
SetAnchors(mcbLabel.GetComponent<RectTransform>(), new Vector2(0f, 0f), new Vector2(1f, 0.25f));
// ── Wire DeckBuilderController via reflection ──────────────────────────
var dbControllerGo = new GameObject("DeckBuilderController");
dbControllerGo.transform.SetParent(canvasGo.transform, false);
var dbController = dbControllerGo.AddComponent<CommanderAILab.UI.DeckBuilderController>();
// Navigation
SetField(dbController, "backButton", backBtn.GetComponent<Button>());
SetField(dbController, "exportToSimButton", exportBtn.GetComponent<Button>());
// Deck List Panel
SetField(dbController, "deckListParent", (Transform)dcrect);
SetField(dbController, "deckRowPrefab", deckRowPrefab);
SetField(dbController, "newDeckButton", newDeckBtn.GetComponent<Button>());
SetField(dbController, "deckNameInput", deckNameInput.GetComponent<TMP_InputField>());
// Active Deck Panel
SetField(dbController, "deckTitleText", deckTitleTxt.GetComponent<TMP_Text>());
SetField(dbController, "cardCountText", cardCountTxt.GetComponent<TMP_Text>());
SetField(dbController, "validationIndicator", validIndic);
SetField(dbController, "validationText", validTxt.GetComponent<TMP_Text>());
SetField(dbController, "deckCardListParent", (Transform)deckCardContent);
SetField(dbController, "deckCardRowPrefab", deckCardRowPrefab);
SetField(dbController, "saveDeckButton", saveDeckBtn.GetComponent<Button>());
SetField(dbController, "deleteDeckButton", deleteDeckBtn.GetComponent<Button>());
// Commander Search
SetField(dbController, "commanderSearchInput", cmdInput.GetComponent<TMP_InputField>());
SetField(dbController, "commanderSearchButton", cmdSearchBtn.GetComponent<Button>());
SetField(dbController, "colorIdentityDropdown", colorIdDd.GetComponent<TMP_Dropdown>());
SetField(dbController, "commanderResultParent", (Transform)cmdContent);
SetField(dbController, "commanderResultRowPrefab", commanderResultRowPrefab);
SetField(dbController, "selectedCommanderText", selCmdTxt.GetComponent<TMP_Text>());
// Card Search Panel
SetField(dbController, "cardSearchInput", cardSearchInput.GetComponent<TMP_InputField>());
SetField(dbController, "cardSearchButton", cardSearchBtn.GetComponent<Button>());
SetField(dbController, "cardSearchResultParent", (Transform)cardResultContent);
SetField(dbController, "cardSearchRowPrefab", cardSearchRowPrefab);
// Mana Curve
SetField(dbController, "manaCurveBarParent", manaCurveParent.transform);
SetField(dbController, "manaCurveBarPrefab", manaCurveBarPrefab);
// Color Pie
SetField(dbController, "colorPieSlices", pieSliceImages);
// Error
SetField(dbController, "errorPanel", errorPanel);
SetField(dbController, "errorText", errorText.GetComponent<TMP_Text>());
SetField(dbController, "retryButton", retryBtn.GetComponent<Button>());
// -- Save --
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
AssetDatabase.CreateFolder("Assets", "Scenes");
string path = "Assets/Scenes/DeckBuilder.unity";
EditorSceneManager.SaveScene(scene, path);
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
if (!existing.Any(s => s.path == path))
existing.Add(new EditorBuildSettingsScene(path, true));
EditorBuildSettings.scenes = existing.ToArray();
Debug.Log("[CreateDeckBuilderScene] DeckBuilder scene created and saved to " + path);
}
// -- Helpers --
/// <summary>
/// Creates a Viewport + Content hierarchy inside the given scroll GameObject,
/// wires up the ScrollRect, and returns the Content RectTransform.
/// </summary>
static RectTransform CreateScrollContent(GameObject scrollGo, out ScrollRect scrollRect)
{
scrollRect = scrollGo.GetComponent<ScrollRect>();
if (scrollRect == null)
scrollRect = scrollGo.AddComponent<ScrollRect>();
// Viewport
var viewport = new GameObject("Viewport");
viewport.transform.SetParent(scrollGo.transform, false);
viewport.AddComponent<RectMask2D>();
var vpRect = viewport.GetComponent<RectTransform>();
vpRect.anchorMin = Vector2.zero;
vpRect.anchorMax = Vector2.one;
vpRect.offsetMin = Vector2.zero;
vpRect.offsetMax = Vector2.zero;
// Content
var content = new GameObject("Content");
content.transform.SetParent(viewport.transform, false);
var contentRect = content.AddComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.offsetMin = Vector2.zero;
contentRect.offsetMax = Vector2.zero;
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var vlg = content.AddComponent<VerticalLayoutGroup>();
vlg.spacing = 4;
vlg.padding = new RectOffset(4, 4, 4, 4);
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = false;
scrollRect.viewport = vpRect;
scrollRect.content = contentRect;
scrollRect.horizontal = false;
return contentRect;
}
static void SetField(object target, string fieldName, object value)
{
var field = target.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null) field.SetValue(target, value);
else Debug.LogWarning("Could not find field: " + fieldName);
}
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
{
rt.anchorMin = min;
rt.anchorMax = max;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = color;
var rt = go.GetComponent<RectTransform>();
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.fontStyle = style;
tmp.alignment = alignment;
tmp.color = Color.white;
var rt = go.GetComponent<RectTransform>();
SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = bgColor;
var btn = go.AddComponent<Button>();
btn.targetGraphic = img;
var colors = btn.colors;
colors.highlightedColor = bgColor * 1.2f;
colors.pressedColor = bgColor * 0.8f;
btn.colors = colors;
var textGo = new GameObject("Text");
textGo.transform.SetParent(go.transform, false);
var tmp = textGo.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 22;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var le = go.AddComponent<LayoutElement>();
le.minHeight = 50;
return go;
}
static GameObject CreateTMPDropdown(Transform parent, string name, string[] options)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
var labelGo = new GameObject("Label");
labelGo.transform.SetParent(go.transform, false);
var labelTmp = labelGo.AddComponent<TextMeshProUGUI>();
labelTmp.text = options.Length > 0 ? options[0] : "";
labelTmp.fontSize = 16;
labelTmp.alignment = TextAlignmentOptions.Left;
labelTmp.color = Color.white;
SetAnchors(labelGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var dd = go.AddComponent<TMP_Dropdown>();
dd.captionText = labelTmp;
dd.ClearOptions();
dd.AddOptions(new System.Collections.Generic.List<string>(options));
var le = go.AddComponent<LayoutElement>();
le.minHeight = 40;
return go;
}
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
var textArea = new GameObject("Text Area");
textArea.transform.SetParent(go.transform, false);
textArea.AddComponent<RectMask2D>();
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var placeholder = new GameObject("Placeholder");
placeholder.transform.SetParent(textArea.transform, false);
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
phTmp.text = defaultText;
phTmp.fontSize = 18;
phTmp.fontStyle = FontStyles.Italic;
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
phTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputText = new GameObject("Text");
inputText.transform.SetParent(textArea.transform, false);
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
itTmp.fontSize = 18;
itTmp.color = Color.white;
itTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputField = go.AddComponent<TMP_InputField>();
inputField.textViewport = textArea.GetComponent<RectTransform>();
inputField.textComponent = itTmp;
inputField.placeholder = phTmp;
inputField.text = defaultText;
inputField.fontAsset = itTmp.font;
return go;
}
}
#endif
@@ -0,0 +1,248 @@
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using UnityEditor;
using UnityEditor.SceneManagement;
using TMPro;
using System.Reflection;
using System.Linq;
public static class CreateMainMenuScene
{
[MenuItem("Tools/Create MainMenu Scene")]
public static void Create()
{
// -- Create new scene --
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
// -- Services (persistent singletons) --
var services = new GameObject("[Services]");
services.AddComponent<CommanderAILab.Services.ApiClient>();
services.AddComponent<CommanderAILab.Services.ImageCache>();
// -- Canvas --
var canvasGo = new GameObject("Canvas");
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
// -- Event System --
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
// -- Background --
var bg = CreatePanel(canvasGo.transform, "Background", Color.black, stretch: true);
bg.GetComponent<Image>().color = new Color(0.08f, 0.08f, 0.12f, 1f);
// -- Server Panel (top) --
var serverPanel = CreatePanel(canvasGo.transform, "ServerPanel", new Color(0.12f, 0.12f, 0.18f, 0.95f));
var spRect = serverPanel.GetComponent<RectTransform>();
SetAnchors(spRect, new Vector2(0.1f, 0.75f), new Vector2(0.9f, 0.95f));
var logoText = CreateTMPText(serverPanel.transform, "LogoText", "Commander AI Lab", 36, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(logoText.GetComponent<RectTransform>(), new Vector2(0f, 0.5f), new Vector2(1f, 1f));
var urlInput = CreateTMPInputField(serverPanel.transform, "ServerUrlInput", "http://localhost:8080");
var urlRect = urlInput.GetComponent<RectTransform>();
SetAnchors(urlRect, new Vector2(0.05f, 0.05f), new Vector2(0.55f, 0.45f));
var connectBtn = CreateTMPButton(serverPanel.transform, "ConnectButton", "Connect", new Color(0.2f, 0.6f, 0.2f));
SetAnchors(connectBtn.GetComponent<RectTransform>(), new Vector2(0.58f, 0.05f), new Vector2(0.75f, 0.45f));
var statusImg = new GameObject("StatusIndicator");
statusImg.transform.SetParent(serverPanel.transform, false);
var statusImage = statusImg.AddComponent<Image>();
statusImage.color = new Color(0.8f, 0.2f, 0.2f);
var siRect = statusImg.GetComponent<RectTransform>();
SetAnchors(siRect, new Vector2(0.78f, 0.15f), new Vector2(0.80f, 0.35f));
var statusText = CreateTMPText(serverPanel.transform, "StatusText", "Disconnected", 18, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(statusText.GetComponent<RectTransform>(), new Vector2(0.81f, 0.05f), new Vector2(0.98f, 0.45f));
// -- Nav Panel (center) --
var navPanel = CreatePanel(canvasGo.transform, "NavPanel", new Color(0.1f, 0.1f, 0.15f, 0.9f));
SetAnchors(navPanel.GetComponent<RectTransform>(), new Vector2(0.1f, 0.15f), new Vector2(0.9f, 0.72f));
// Add vertical layout
var vlg = navPanel.AddComponent<VerticalLayoutGroup>();
vlg.spacing = 10;
vlg.padding = new RectOffset(40, 40, 20, 20);
vlg.childAlignment = TextAnchor.MiddleCenter;
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = true;
string[] btnNames = { "CollectionButton", "DeckBuilderButton", "DeckGenButton", "SimulatorButton", "ScannerButton", "CoachButton", "TrainingButton" };
string[] btnLabels = { "Collection", "Deck Builder", "Deck Generator", "Simulator", "Scanner", "Coach", "Training" };
var navButtons = new GameObject[btnNames.Length];
for (int i = 0; i < btnNames.Length; i++)
{
navButtons[i] = CreateTMPButton(navPanel.transform, btnNames[i], btnLabels[i], new Color(0.18f, 0.22f, 0.35f));
}
// -- Error Panel (bottom, inactive) --
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.5f, 0.1f, 0.1f, 0.95f));
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.2f, 0.02f), new Vector2(0.8f, 0.12f));
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.Center);
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0f, 0.3f), new Vector2(0.7f, 1f));
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.6f, 0.3f, 0.1f));
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.72f, 0.15f), new Vector2(0.95f, 0.85f));
errorPanel.SetActive(false);
// -- Wire up MainMenuController --
var controllerGo = new GameObject("MainMenuController");
var controller = controllerGo.AddComponent<CommanderAILab.UI.MainMenuController>();
// Use reflection to set private serialized fields
SetField(controller, "serverUrlInput", urlInput.GetComponent<TMP_InputField>());
SetField(controller, "connectButton", connectBtn.GetComponent<Button>());
SetField(controller, "statusIndicator", statusImage);
SetField(controller, "statusText", statusText.GetComponent<TMP_Text>());
SetField(controller, "collectionButton", navButtons[0].GetComponent<Button>());
SetField(controller, "deckBuilderButton", navButtons[1].GetComponent<Button>());
SetField(controller, "deckGenButton", navButtons[2].GetComponent<Button>());
SetField(controller, "simulatorButton", navButtons[3].GetComponent<Button>());
SetField(controller, "scannerButton", navButtons[4].GetComponent<Button>());
SetField(controller, "coachButton", navButtons[5].GetComponent<Button>());
SetField(controller, "trainingButton", navButtons[6].GetComponent<Button>());
SetField(controller, "errorPanel", errorPanel);
SetField(controller, "errorText", errorText.GetComponent<TMP_Text>());
SetField(controller, "retryButton", retryBtn.GetComponent<Button>());
// -- Save --
string path = "Assets/Scenes/MainMenu.unity";
EditorSceneManager.SaveScene(scene, path);
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
if (!existing.Any(s => s.path == path))
existing.Add(new EditorBuildSettingsScene(path, true));
EditorBuildSettings.scenes = existing.ToArray();
Debug.Log("MainMenu scene created and saved to " + path);
EditorUtility.DisplayDialog("Done", "MainMenu scene created!\n\nHit Play to test.\nMake sure your FastAPI backend is running on port 8080.", "OK");
}
// -- Helpers --
static void SetField(object target, string fieldName, object value)
{
var field = target.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null) field.SetValue(target, value);
else Debug.LogWarning("Could not find field: " + fieldName);
}
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
{
rt.anchorMin = min;
rt.anchorMax = max;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = color;
var rt = go.GetComponent<RectTransform>();
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.fontStyle = style;
tmp.alignment = alignment;
tmp.color = Color.white;
var rt = go.GetComponent<RectTransform>();
SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = bgColor;
var btn = go.AddComponent<Button>();
btn.targetGraphic = img;
var colors = btn.colors;
colors.highlightedColor = bgColor * 1.2f;
colors.pressedColor = bgColor * 0.8f;
btn.colors = colors;
var textGo = new GameObject("Text");
textGo.transform.SetParent(go.transform, false);
var tmp = textGo.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 22;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var le = go.AddComponent<LayoutElement>();
le.minHeight = 50;
return go;
}
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
// Text Area
var textArea = new GameObject("Text Area");
textArea.transform.SetParent(go.transform, false);
textArea.AddComponent<RectMask2D>();
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
// Placeholder
var placeholder = new GameObject("Placeholder");
placeholder.transform.SetParent(textArea.transform, false);
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
phTmp.text = "Enter server URL...";
phTmp.fontSize = 18;
phTmp.fontStyle = FontStyles.Italic;
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
phTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
// Input text
var inputText = new GameObject("Text");
inputText.transform.SetParent(textArea.transform, false);
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
itTmp.fontSize = 18;
itTmp.color = Color.white;
itTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputField = go.AddComponent<TMP_InputField>();
inputField.textViewport = textArea.GetComponent<RectTransform>();
inputField.textComponent = itTmp;
inputField.placeholder = phTmp;
inputField.text = defaultText;
inputField.fontAsset = itTmp.font;
return go;
}
}
#endif
@@ -0,0 +1,167 @@
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using UnityEditor.SceneManagement;
using TMPro;
using System.Reflection;
using System.Linq;
public static class CreateScannerScene
{
[MenuItem("Tools/Create Scanner Scene")]
public static void Create()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
var services = new GameObject("[Services]");
services.AddComponent<CommanderAILab.Services.ApiClient>();
services.AddComponent<CommanderAILab.Services.ImageCache>();
var canvasGo = new GameObject("Canvas");
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
CreatePanel(canvasGo.transform, "Background", new Color(0.05f, 0.05f, 0.08f), stretch: true);
// Header
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f));
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f));
var title = CreateTMPText(header.transform, "TitleText", "Card Scanner", 28, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(title.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
// Camera feed (left)
var camFeedGo = new GameObject("CameraFeedImage");
camFeedGo.transform.SetParent(canvasGo.transform, false);
camFeedGo.AddComponent<RawImage>().color = Color.black;
SetAnchors(camFeedGo.GetComponent<RectTransform>(), new Vector2(0.01f, 0.20f), new Vector2(0.55f, 0.93f));
// Scan overlay (animated line placeholder)
var scanOverlay = CreatePanel(canvasGo.transform, "ScanOverlay", new Color(0f, 0.8f, 0f, 0.35f));
SetAnchors(scanOverlay.GetComponent<RectTransform>(), new Vector2(0.01f, 0.55f), new Vector2(0.55f, 0.57f));
scanOverlay.SetActive(false);
// Camera status
var camStatus = CreateTMPText(canvasGo.transform, "CameraStatusText", "Initializing camera...", 16, FontStyles.Normal, TextAlignmentOptions.Center);
SetAnchors(camStatus.GetComponent<RectTransform>(), new Vector2(0.01f, 0.15f), new Vector2(0.55f, 0.20f));
// Capture + toggle buttons
var captureBtn = CreateTMPButton(canvasGo.transform, "CaptureButton", "Scan Card", new Color(0.15f, 0.6f, 0.15f));
SetAnchors(captureBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.07f), new Vector2(0.32f, 0.14f));
var toggleCamBtn = CreateTMPButton(canvasGo.transform, "ToggleCameraButton", "Flip Camera", new Color(0.2f, 0.3f, 0.5f));
SetAnchors(toggleCamBtn.GetComponent<RectTransform>(), new Vector2(0.34f, 0.07f), new Vector2(0.54f, 0.14f));
// Result panel (right)
var resultPanel = CreatePanel(canvasGo.transform, "ResultPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(resultPanel.GetComponent<RectTransform>(), new Vector2(0.56f, 0.20f), new Vector2(0.99f, 0.93f));
var resultImg = new GameObject("ResultCardImage");
resultImg.transform.SetParent(resultPanel.transform, false);
resultImg.AddComponent<Image>().color = new Color(0.15f, 0.15f, 0.2f);
SetAnchors(resultImg.GetComponent<RectTransform>(), new Vector2(0.03f, 0.55f), new Vector2(0.45f, 0.98f));
var rName = CreateTMPText(resultPanel.transform, "ResultCardName", "", 22, FontStyles.Bold, TextAlignmentOptions.Left);
SetAnchors(rName.GetComponent<RectTransform>(), new Vector2(0.48f, 0.88f), new Vector2(0.97f, 0.98f));
var rType = CreateTMPText(resultPanel.transform, "ResultTypeLine", "", 16, FontStyles.Italic, TextAlignmentOptions.Left);
SetAnchors(rType.GetComponent<RectTransform>(), new Vector2(0.48f, 0.80f), new Vector2(0.97f, 0.88f));
var rOracle = CreateTMPText(resultPanel.transform, "ResultOracleText","", 14, FontStyles.Normal, TextAlignmentOptions.TopLeft);
SetAnchors(rOracle.GetComponent<RectTransform>(), new Vector2(0.48f, 0.55f), new Vector2(0.97f, 0.80f));
var rStatus = CreateTMPText(resultPanel.transform, "ResultStatus", "", 14, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(rStatus.GetComponent<RectTransform>(), new Vector2(0.03f, 0.45f), new Vector2(0.97f, 0.55f));
var addCollBtn = CreateTMPButton(resultPanel.transform, "AddToCollectionButton", "Add to Collection", new Color(0.2f, 0.55f, 0.2f));
SetAnchors(addCollBtn.GetComponent<RectTransform>(), new Vector2(0.03f, 0.03f), new Vector2(0.48f, 0.12f));
var addDeckBtn = CreateTMPButton(resultPanel.transform, "AddToDeckButton", "Add to Deck", new Color(0.2f, 0.3f, 0.6f));
SetAnchors(addDeckBtn.GetComponent<RectTransform>(), new Vector2(0.52f, 0.03f), new Vector2(0.97f, 0.12f));
resultPanel.SetActive(false);
// -- Save --
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
AssetDatabase.CreateFolder("Assets", "Scenes");
string path = "Assets/Scenes/Scanner.unity";
EditorSceneManager.SaveScene(scene, path);
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
if (!existing.Any(s => s.path == path))
existing.Add(new EditorBuildSettingsScene(path, true));
EditorBuildSettings.scenes = existing.ToArray();
Debug.Log("[CreateScannerScene] Scanner scene created and saved to " + path);
}
// -- Helpers --
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
{
rt.anchorMin = min;
rt.anchorMax = max;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = color;
var rt = go.GetComponent<RectTransform>();
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.fontStyle = style;
tmp.alignment = alignment;
tmp.color = Color.white;
var rt = go.GetComponent<RectTransform>();
SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = bgColor;
var btn = go.AddComponent<Button>();
btn.targetGraphic = img;
var colors = btn.colors;
colors.highlightedColor = bgColor * 1.2f;
colors.pressedColor = bgColor * 0.8f;
btn.colors = colors;
var textGo = new GameObject("Text");
textGo.transform.SetParent(go.transform, false);
var tmp = textGo.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 22;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var le = go.AddComponent<LayoutElement>();
le.minHeight = 50;
return go;
}
}
#endif
@@ -0,0 +1,445 @@
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using UnityEditor.SceneManagement;
using TMPro;
using System.Reflection;
using System.Linq;
public static class CreateSimulatorScene
{
[MenuItem("Tools/Create Simulator Scene")]
public static void Create()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
// ── Services ──────────────────────────────────────────────────────────
var services = new GameObject("[Services]");
services.AddComponent<CommanderAILab.Services.ApiClient>();
// ── Canvas ────────────────────────────────────────────────────────────
var canvasGo = new GameObject("Canvas");
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
// ── Background ────────────────────────────────────────────────────────
CreatePanel(canvasGo.transform, "Background", new Color(0.08f, 0.08f, 0.12f), stretch: true);
// ── Header (Top Bar) ──────────────────────────────────────────────────
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f, 0.95f));
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f));
var titleTxt = CreateTMPText(header.transform, "TitleText", "Simulator", 28, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(titleTxt.GetComponent<RectTransform>(), new Vector2(0.3f, 0.1f), new Vector2(0.7f, 0.9f));
var startSimBtn = CreateTMPButton(header.transform, "StartSimButton", "Start Simulation", new Color(0.2f, 0.55f, 0.2f));
SetAnchors(startSimBtn.GetComponent<RectTransform>(), new Vector2(0.82f, 0.1f), new Vector2(0.99f, 0.9f));
// ── Config Panel (left) ───────────────────────────────────────────────
var configPanel = CreatePanel(canvasGo.transform, "ConfigPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(configPanel.GetComponent<RectTransform>(), new Vector2(0.01f, 0.3f), new Vector2(0.49f, 0.93f));
var cfgVlg = configPanel.AddComponent<VerticalLayoutGroup>();
cfgVlg.padding = new RectOffset(16, 16, 16, 16);
cfgVlg.spacing = 10;
cfgVlg.childForceExpandWidth = true;
cfgVlg.childForceExpandHeight = false;
CreateTMPText(configPanel.transform, "ConfigTitle", "Simulation Config", 20, FontStyles.Bold, TextAlignmentOptions.Center);
// Deck dropdowns
var deckDropdown0 = CreateTMPDropdown(configPanel.transform, "DeckDropdown0", new[] { "-- Select Deck --" });
var deckDropdown1 = CreateTMPDropdown(configPanel.transform, "DeckDropdown1", new[] { "-- Select Deck --" });
var deckDropdown2 = CreateTMPDropdown(configPanel.transform, "DeckDropdown2", new[] { "-- Select Deck --" });
var deckDropdown3 = CreateTMPDropdown(configPanel.transform, "DeckDropdown3", new[] { "-- Select Deck --" });
// Num players slider
var numPlayersLabel = CreateTMPText(configPanel.transform, "NumPlayersLabel", "Players: 2", 16, FontStyles.Normal, TextAlignmentOptions.Left);
var numPlayersSlider = CreateSlider(configPanel.transform, "NumPlayersSlider");
// Num games input
CreateTMPText(configPanel.transform, "NumGamesLabel", "Number of Games:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
var numGamesInput = CreateTMPInputField(configPanel.transform, "NumGamesInput", "100");
// Threads input
CreateTMPText(configPanel.transform, "ThreadsLabel", "Threads:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
var threadsInput = CreateTMPInputField(configPanel.transform, "ThreadsInput", "4");
// AI Profile dropdown
CreateTMPText(configPanel.transform, "AiProfileLabel", "AI Profile:", 16, FontStyles.Normal, TextAlignmentOptions.Left);
var aiProfileDropdown = CreateTMPDropdown(configPanel.transform, "AiProfileDropdown", new[] { "Default" });
// ── Status Panel (center, hidden initially) ───────────────────────────
var statusPanel = CreatePanel(canvasGo.transform, "StatusPanel", new Color(0.09f, 0.09f, 0.14f, 0.97f));
SetAnchors(statusPanel.GetComponent<RectTransform>(), new Vector2(0.01f, 0.06f), new Vector2(0.49f, 0.29f));
statusPanel.SetActive(false);
var stVlg = statusPanel.AddComponent<VerticalLayoutGroup>();
stVlg.padding = new RectOffset(12, 12, 10, 10);
stVlg.spacing = 6;
stVlg.childForceExpandWidth = true;
stVlg.childForceExpandHeight = false;
var statusText = CreateTMPText(statusPanel.transform, "StatusText", "Ready", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
var progressBar = CreateSlider(statusPanel.transform, "ProgressBar");
var progressText = CreateTMPText(statusPanel.transform, "ProgressText", "0%", 14, FontStyles.Normal, TextAlignmentOptions.Center);
var elapsedText = CreateTMPText(statusPanel.transform, "ElapsedText", "Elapsed: 0s", 14, FontStyles.Normal, TextAlignmentOptions.Left);
var simsPerSecText = CreateTMPText(statusPanel.transform, "SimsPerSecText", "0 sims/s", 14, FontStyles.Normal, TextAlignmentOptions.Left);
// ── Results Panel (right, hidden initially) ───────────────────────────
var resultsPanel = CreatePanel(canvasGo.transform, "ResultsPanel", new Color(0.1f, 0.1f, 0.16f, 0.97f));
SetAnchors(resultsPanel.GetComponent<RectTransform>(), new Vector2(0.51f, 0.06f), new Vector2(0.99f, 0.93f));
resultsPanel.SetActive(false);
CreateTMPText(resultsPanel.transform, "ResultsTitle", "Results", 22, FontStyles.Bold, TextAlignmentOptions.Center);
var winnerText = CreateTMPText(resultsPanel.transform, "WinnerText", "Winner: —", 18, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(winnerText.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(1f, 0.95f));
// Results scroll view
var resultsScroll = new GameObject("ResultsScroll");
resultsScroll.transform.SetParent(resultsPanel.transform, false);
SetAnchors(resultsScroll.GetComponent<RectTransform>(), new Vector2(0f, 0.15f), new Vector2(1f, 0.88f));
ScrollRect resSr;
var resultsContent = CreateScrollContent(resultsScroll, out resSr);
var historyBtn = CreateTMPButton(resultsPanel.transform, "HistoryButton", "View History", new Color(0.2f, 0.4f, 0.7f));
SetAnchors(historyBtn.GetComponent<RectTransform>(), new Vector2(0.2f, 0.03f), new Vector2(0.8f, 0.13f));
// ── Error Panel (hidden initially) ────────────────────────────────────
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.15f, 0.05f, 0.05f, 0.97f));
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.25f, 0.35f), new Vector2(0.75f, 0.65f));
errorPanel.SetActive(false);
CreateTMPText(errorPanel.transform, "ErrorTitle", "Error", 22, FontStyles.Bold, TextAlignmentOptions.Center);
var errorText = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.TopLeft);
SetAnchors(errorText.GetComponent<RectTransform>(), new Vector2(0.05f, 0.25f), new Vector2(0.95f, 0.85f));
var retryBtn = CreateTMPButton(errorPanel.transform, "RetryButton", "Retry", new Color(0.55f, 0.2f, 0.2f));
SetAnchors(retryBtn.GetComponent<RectTransform>(), new Vector2(0.3f, 0.05f), new Vector2(0.7f, 0.22f));
// ── Result Row Prefab (disabled child object used as prefab) ──────────
var resultRowPrefab = new GameObject("ResultRowPrefab");
resultRowPrefab.transform.SetParent(canvasGo.transform, false);
resultRowPrefab.SetActive(false);
var rowImg = resultRowPrefab.AddComponent<Image>();
rowImg.color = new Color(0.15f, 0.15f, 0.2f, 0.9f);
var rowHlg = resultRowPrefab.AddComponent<HorizontalLayoutGroup>();
rowHlg.padding = new RectOffset(8, 8, 4, 4);
rowHlg.spacing = 12;
rowHlg.childForceExpandWidth = false;
rowHlg.childForceExpandHeight = true;
var rowLe = resultRowPrefab.AddComponent<LayoutElement>();
rowLe.minHeight = 40;
string[] colNames = { "DeckName", "WinRate", "Wins", "Losses" };
float[] colWidths = { 300f, 120f, 100f, 100f };
for (int i = 0; i < colNames.Length; i++)
{
var cell = new GameObject(colNames[i]);
cell.transform.SetParent(resultRowPrefab.transform, false);
var cellTxt = cell.AddComponent<TextMeshProUGUI>();
cellTxt.text = colNames[i];
cellTxt.fontSize = 16;
cellTxt.color = Color.white;
cellTxt.alignment = TextAlignmentOptions.Left;
var cellLe = cell.AddComponent<LayoutElement>();
cellLe.minWidth = colWidths[i];
cellLe.preferredWidth = colWidths[i];
cellLe.flexibleWidth = i == 0 ? 1f : 0f;
}
// ── Wire SimulationController via reflection ───────────────────────────
var controllerGo = new GameObject("SimulationController");
controllerGo.transform.SetParent(canvasGo.transform, false);
var controller = controllerGo.AddComponent<CommanderAILab.UI.SimulationController>();
SetField(controller, "backButton", backBtn.GetComponent<Button>());
SetField(controller, "startSimButton", startSimBtn.GetComponent<Button>());
SetField(controller, "deckDropdown0", deckDropdown0.GetComponent<TMP_Dropdown>());
SetField(controller, "deckDropdown1", deckDropdown1.GetComponent<TMP_Dropdown>());
SetField(controller, "deckDropdown2", deckDropdown2.GetComponent<TMP_Dropdown>());
SetField(controller, "deckDropdown3", deckDropdown3.GetComponent<TMP_Dropdown>());
SetField(controller, "numGamesInput", numGamesInput.GetComponent<TMP_InputField>());
SetField(controller, "numPlayersSlider", numPlayersSlider.GetComponent<Slider>());
SetField(controller, "numPlayersLabel", numPlayersLabel.GetComponent<TMP_Text>());
SetField(controller, "threadsInput", threadsInput.GetComponent<TMP_InputField>());
SetField(controller, "aiProfileDropdown", aiProfileDropdown.GetComponent<TMP_Dropdown>());
SetField(controller, "statusText", statusText.GetComponent<TMP_Text>());
SetField(controller, "progressBar", progressBar.GetComponent<Slider>());
SetField(controller, "progressText", progressText.GetComponent<TMP_Text>());
SetField(controller, "elapsedText", elapsedText.GetComponent<TMP_Text>());
SetField(controller, "simsPerSecText", simsPerSecText.GetComponent<TMP_Text>());
SetField(controller, "resultsParent", resultsContent);
SetField(controller, "resultRowPrefab", resultRowPrefab);
SetField(controller, "winnerText", winnerText.GetComponent<TMP_Text>());
SetField(controller, "historyButton", historyBtn.GetComponent<Button>());
SetField(controller, "errorPanel", errorPanel);
SetField(controller, "errorText", errorText.GetComponent<TMP_Text>());
SetField(controller, "retryButton", retryBtn.GetComponent<Button>());
// ── Save ──────────────────────────────────────────────────────────────
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
AssetDatabase.CreateFolder("Assets", "Scenes");
string path = "Assets/Scenes/Simulator.unity";
EditorSceneManager.SaveScene(scene, path);
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
if (!existing.Any(s => s.path == path))
existing.Add(new EditorBuildSettingsScene(path, true));
EditorBuildSettings.scenes = existing.ToArray();
Debug.Log("[CreateSimulatorScene] Simulator scene created and saved to " + path);
}
// ── Helpers ───────────────────────────────────────────────────────────────
static void SetField(object target, string fieldName, object value)
{
var field = target.GetType().GetField(fieldName,
BindingFlags.NonPublic | BindingFlags.Instance);
if (field != null) field.SetValue(target, value);
else Debug.LogWarning("[CreateSimulatorScene] Could not find field: " + fieldName);
}
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
{
rt.anchorMin = min;
rt.anchorMax = max;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = color;
var rt = go.GetComponent<RectTransform>();
if (stretch) SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPText(Transform parent, string name, string text, int fontSize, FontStyles style, TextAlignmentOptions alignment)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = fontSize;
tmp.fontStyle = style;
tmp.alignment = alignment;
tmp.color = Color.white;
var rt = go.GetComponent<RectTransform>();
SetAnchors(rt, Vector2.zero, Vector2.one);
return go;
}
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = bgColor;
var btn = go.AddComponent<Button>();
btn.targetGraphic = img;
var colors = btn.colors;
colors.highlightedColor = bgColor * 1.2f;
colors.pressedColor = bgColor * 0.8f;
btn.colors = colors;
var textGo = new GameObject("Text");
textGo.transform.SetParent(go.transform, false);
var tmp = textGo.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 22;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
SetAnchors(textGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var le = go.AddComponent<LayoutElement>();
le.minHeight = 50;
return go;
}
static GameObject CreateTMPInputField(Transform parent, string name, string defaultText)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
var textArea = new GameObject("Text Area");
textArea.transform.SetParent(go.transform, false);
textArea.AddComponent<RectMask2D>();
SetAnchors(textArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var placeholder = new GameObject("Placeholder");
placeholder.transform.SetParent(textArea.transform, false);
var phTmp = placeholder.AddComponent<TextMeshProUGUI>();
phTmp.text = defaultText;
phTmp.fontSize = 18;
phTmp.fontStyle = FontStyles.Italic;
phTmp.color = new Color(0.5f, 0.5f, 0.5f, 0.8f);
phTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(placeholder.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputText = new GameObject("Text");
inputText.transform.SetParent(textArea.transform, false);
var itTmp = inputText.AddComponent<TextMeshProUGUI>();
itTmp.fontSize = 18;
itTmp.color = Color.white;
itTmp.alignment = TextAlignmentOptions.Left;
SetAnchors(inputText.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var inputField = go.AddComponent<TMP_InputField>();
inputField.textViewport = textArea.GetComponent<RectTransform>();
inputField.textComponent = itTmp;
inputField.placeholder = phTmp;
inputField.text = defaultText;
inputField.fontAsset = itTmp.font;
var le = go.AddComponent<LayoutElement>();
le.minHeight = 45;
return go;
}
static GameObject CreateTMPDropdown(Transform parent, string name, string[] options)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = new Color(0.15f, 0.15f, 0.2f);
var labelGo = new GameObject("Label");
labelGo.transform.SetParent(go.transform, false);
var labelTmp = labelGo.AddComponent<TextMeshProUGUI>();
labelTmp.text = options.Length > 0 ? options[0] : "";
labelTmp.fontSize = 16;
labelTmp.alignment = TextAlignmentOptions.Left;
labelTmp.color = Color.white;
SetAnchors(labelGo.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var dd = go.AddComponent<TMP_Dropdown>();
dd.captionText = labelTmp;
dd.ClearOptions();
dd.AddOptions(new System.Collections.Generic.List<string>(options));
var le = go.AddComponent<LayoutElement>();
le.minHeight = 40;
return go;
}
static GameObject CreateSlider(Transform parent, string name)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
// Background track
var bg = new GameObject("Background");
bg.transform.SetParent(go.transform, false);
var bgImg = bg.AddComponent<Image>();
bgImg.color = new Color(0.2f, 0.2f, 0.25f);
SetAnchors(bg.GetComponent<RectTransform>(), new Vector2(0f, 0.25f), new Vector2(1f, 0.75f));
// Fill area
var fillArea = new GameObject("Fill Area");
fillArea.transform.SetParent(go.transform, false);
SetAnchors(fillArea.GetComponent<RectTransform>(), new Vector2(0f, 0.25f), new Vector2(1f, 0.75f));
var fill = new GameObject("Fill");
fill.transform.SetParent(fillArea.transform, false);
var fillImg = fill.AddComponent<Image>();
fillImg.color = new Color(0.2f, 0.6f, 0.3f);
var fillRt = fill.GetComponent<RectTransform>();
fillRt.anchorMin = Vector2.zero;
fillRt.anchorMax = Vector2.one;
fillRt.offsetMin = Vector2.zero;
fillRt.offsetMax = Vector2.zero;
// Handle
var handleArea = new GameObject("Handle Slide Area");
handleArea.transform.SetParent(go.transform, false);
SetAnchors(handleArea.GetComponent<RectTransform>(), Vector2.zero, Vector2.one);
var handle = new GameObject("Handle");
handle.transform.SetParent(handleArea.transform, false);
var handleImg = handle.AddComponent<Image>();
handleImg.color = new Color(0.8f, 0.8f, 0.9f);
var handleRt = handle.GetComponent<RectTransform>();
handleRt.sizeDelta = new Vector2(20f, 20f);
var slider = go.AddComponent<Slider>();
slider.fillRect = fillRt;
slider.handleRect = handleRt;
slider.targetGraphic = handleImg;
slider.direction = Slider.Direction.LeftToRight;
slider.minValue = 0f;
slider.maxValue = 1f;
slider.value = 0f;
var le = go.AddComponent<LayoutElement>();
le.minHeight = 30;
return go;
}
/// <summary>
/// Creates a Viewport + Content hierarchy inside the given scroll GameObject,
/// wires up the ScrollRect, and returns the Content RectTransform.
/// </summary>
static RectTransform CreateScrollContent(GameObject scrollGo, out ScrollRect scrollRect)
{
scrollRect = scrollGo.GetComponent<ScrollRect>();
if (scrollRect == null)
scrollRect = scrollGo.AddComponent<ScrollRect>();
var viewport = new GameObject("Viewport");
viewport.transform.SetParent(scrollGo.transform, false);
viewport.AddComponent<RectMask2D>();
var vpRect = viewport.GetComponent<RectTransform>();
vpRect.anchorMin = Vector2.zero;
vpRect.anchorMax = Vector2.one;
vpRect.offsetMin = Vector2.zero;
vpRect.offsetMax = Vector2.zero;
var content = new GameObject("Content");
content.transform.SetParent(viewport.transform, false);
var contentRect = content.AddComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0f, 1f);
contentRect.anchorMax = new Vector2(1f, 1f);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.offsetMin = Vector2.zero;
contentRect.offsetMax = Vector2.zero;
content.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var vlg = content.AddComponent<VerticalLayoutGroup>();
vlg.spacing = 4;
vlg.padding = new RectOffset(4, 4, 4, 4);
vlg.childForceExpandWidth = true;
vlg.childForceExpandHeight = false;
scrollRect.viewport = vpRect;
scrollRect.content = contentRect;
scrollRect.horizontal = false;
return contentRect;
}
}
#endif
@@ -0,0 +1,251 @@
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using UnityEditor.SceneManagement;
using TMPro;
using System.Reflection;
using System.Linq;
public static class CreateTrainingScene
{
[MenuItem("Tools/Create Training Scene")]
public static void Create()
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single);
var services = new GameObject("[Services]");
services.AddComponent<CommanderAILab.Services.ApiClient>();
var canvasGo = new GameObject("Canvas");
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
if (Object.FindObjectOfType<UnityEngine.EventSystems.EventSystem>() == null)
{
var es = new GameObject("EventSystem");
es.AddComponent<UnityEngine.EventSystems.EventSystem>();
es.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
CreatePanel(canvasGo.transform, "Background", new Color(0.05f, 0.05f, 0.08f), stretch: true);
// Header
var header = CreatePanel(canvasGo.transform, "Header", new Color(0.12f, 0.12f, 0.18f));
SetAnchors(header.GetComponent<RectTransform>(), new Vector2(0f, 0.93f), new Vector2(1f, 1f));
var backBtn = CreateTMPButton(header.transform, "BackButton", "< Back", new Color(0.2f, 0.2f, 0.3f));
SetAnchors(backBtn.GetComponent<RectTransform>(), new Vector2(0.01f, 0.1f), new Vector2(0.08f, 0.9f));
var title = CreateTMPText(header.transform, "TitleText", "ML Training Dashboard", 28, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(title.GetComponent<RectTransform>(), new Vector2(0.1f, 0.1f), new Vector2(0.9f, 0.9f));
// Left column: Stats + Controls
var leftCol = CreatePanel(canvasGo.transform, "LeftColumn", new Color(0.08f, 0.08f, 0.12f));
SetAnchors(leftCol.GetComponent<RectTransform>(), new Vector2(0f, 0.01f), new Vector2(0.28f, 0.92f));
// Stats panel
var statsPanel = CreatePanel(leftCol.transform, "StatsPanel", new Color(0.12f, 0.12f, 0.18f));
SetAnchors(statsPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.72f), new Vector2(0.98f, 0.99f));
var statsTitle = CreateTMPText(statsPanel.transform, "StatsTitle", "TRAINING DATA", 16, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(statsTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.78f), new Vector2(1f, 1f));
var cardCountTxt = CreateTMPText(statsPanel.transform, "CardCountText", "Cards: --", 14, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(cardCountTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.55f), new Vector2(0.95f, 0.77f));
var sessionCountTxt = CreateTMPText(statsPanel.transform, "SessionCountText", "Sessions: --", 14, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(sessionCountTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.33f), new Vector2(0.95f, 0.55f));
var modelVerTxt = CreateTMPText(statsPanel.transform, "ModelVersionText", "Model: --", 14, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(modelVerTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.11f), new Vector2(0.95f, 0.33f));
var lastTrainedTxt = CreateTMPText(statsPanel.transform, "LastTrainedText", "Last trained: --", 12, FontStyles.Normal, TextAlignmentOptions.Left);
SetAnchors(lastTrainedTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0f), new Vector2(0.95f, 0.11f));
// Controls panel
var ctrlPanel = CreatePanel(leftCol.transform, "ControlsPanel", new Color(0.12f, 0.12f, 0.18f));
SetAnchors(ctrlPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.42f), new Vector2(0.98f, 0.71f));
var statusTxt = CreateTMPText(ctrlPanel.transform, "StatusText", "Ready.", 13, FontStyles.Normal, TextAlignmentOptions.Center);
SetAnchors(statusTxt.GetComponent<RectTransform>(), new Vector2(0.02f, 0.75f), new Vector2(0.98f, 1f));
var startBtn = CreateTMPButton(ctrlPanel.transform, "StartTrainingButton", "Start Training", new Color(0.1f, 0.5f, 0.1f));
SetAnchors(startBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.45f), new Vector2(0.95f, 0.73f));
var stopBtn = CreateTMPButton(ctrlPanel.transform, "StopTrainingButton", "Stop", new Color(0.5f, 0.1f, 0.1f));
SetAnchors(stopBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.15f), new Vector2(0.95f, 0.43f));
var exportBtn = CreateTMPButton(ctrlPanel.transform, "ExportModelButton", "Export Model", new Color(0.2f, 0.3f, 0.5f));
SetAnchors(exportBtn.GetComponent<RectTransform>(), new Vector2(0.05f, 0.01f), new Vector2(0.95f, 0.14f));
// Center column: Live Charts
var centerCol = CreatePanel(canvasGo.transform, "CenterColumn", new Color(0.08f, 0.08f, 0.12f));
SetAnchors(centerCol.GetComponent<RectTransform>(), new Vector2(0.29f, 0.01f), new Vector2(0.69f, 0.92f));
var accLabel = CreateTMPText(centerCol.transform, "AccuracyLabel", "Accuracy: --", 14, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(accLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.87f), new Vector2(1f, 0.93f));
var accChart = CreatePanel(centerCol.transform, "AccuracyChart", new Color(0.05f, 0.15f, 0.05f));
SetAnchors(accChart.GetComponent<RectTransform>(), new Vector2(0.02f, 0.5f), new Vector2(0.98f, 0.86f));
var lossLabel = CreateTMPText(centerCol.transform, "LossLabel", "Loss: --", 14, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(lossLabel.GetComponent<RectTransform>(), new Vector2(0f, 0.43f), new Vector2(1f, 0.49f));
var lossChart = CreatePanel(centerCol.transform, "LossChart", new Color(0.15f, 0.05f, 0.05f));
SetAnchors(lossChart.GetComponent<RectTransform>(), new Vector2(0.02f, 0.06f), new Vector2(0.98f, 0.42f));
// Feature importance title
var featTitle = CreateTMPText(centerCol.transform, "FeatureTitle", "Feature Importance", 13, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(featTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.01f), new Vector2(1f, 0.05f));
// Right column: Model History + Log
var rightCol = CreatePanel(canvasGo.transform, "RightColumn", new Color(0.08f, 0.08f, 0.12f));
SetAnchors(rightCol.GetComponent<RectTransform>(), new Vector2(0.7f, 0.01f), new Vector2(1f, 0.92f));
// History panel
var histPanel = CreatePanel(rightCol.transform, "HistoryPanel", new Color(0.12f, 0.12f, 0.18f));
SetAnchors(histPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.52f), new Vector2(0.98f, 0.99f));
var histTitle = CreateTMPText(histPanel.transform, "HistoryTitle", "MODEL HISTORY", 15, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(histTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(1f, 1f));
var histScrollGo = new GameObject("HistoryScroll");
histScrollGo.transform.SetParent(histPanel.transform, false);
var histScroll = histScrollGo.AddComponent<ScrollRect>();
var histScrollRT = histScrollGo.GetComponent<RectTransform>();
SetAnchors(histScrollRT, new Vector2(0.01f, 0.01f), new Vector2(0.99f, 0.87f));
var histContent = new GameObject("Content");
histContent.transform.SetParent(histScrollGo.transform, false);
var histContentRT = histContent.AddComponent<RectTransform>();
histContentRT.anchorMin = Vector2.zero; histContentRT.anchorMax = new Vector2(1f, 0f);
histContentRT.pivot = new Vector2(0.5f, 0f);
histContent.AddComponent<VerticalLayoutGroup>().spacing = 4;
histContent.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
histScroll.content = histContentRT;
histScroll.vertical = true; histScroll.horizontal = false;
// Log panel
var logPanel = CreatePanel(rightCol.transform, "LogPanel", new Color(0.06f, 0.06f, 0.06f));
SetAnchors(logPanel.GetComponent<RectTransform>(), new Vector2(0.02f, 0.01f), new Vector2(0.98f, 0.51f));
var logTitle = CreateTMPText(logPanel.transform, "LogTitle", "TRAINING LOG", 14, FontStyles.Bold, TextAlignmentOptions.Center);
SetAnchors(logTitle.GetComponent<RectTransform>(), new Vector2(0f, 0.88f), new Vector2(1f, 1f));
var logScrollGo = new GameObject("LogScroll");
logScrollGo.transform.SetParent(logPanel.transform, false);
var logScroll = logScrollGo.AddComponent<ScrollRect>();
SetAnchors(logScrollGo.GetComponent<RectTransform>(), new Vector2(0.01f, 0.01f), new Vector2(0.99f, 0.87f));
var logContent = new GameObject("LogContent");
logContent.transform.SetParent(logScrollGo.transform, false);
var logContentRT = logContent.AddComponent<RectTransform>();
logContentRT.anchorMin = Vector2.zero; logContentRT.anchorMax = new Vector2(1f, 0f);
logContentRT.pivot = new Vector2(0.5f, 0f);
var logText = logContent.AddComponent<TextMeshProUGUI>();
logText.fontSize = 11; logText.color = new Color(0.7f, 1f, 0.7f);
logText.text = "";
logContent.AddComponent<ContentSizeFitter>().verticalFit = ContentSizeFitter.FitMode.PreferredSize;
logScroll.content = logContentRT;
logScroll.vertical = true; logScroll.horizontal = false;
// Error panel
var errorPanel = CreatePanel(canvasGo.transform, "ErrorPanel", new Color(0.4f, 0.05f, 0.05f, 0.95f));
SetAnchors(errorPanel.GetComponent<RectTransform>(), new Vector2(0.3f, 0.4f), new Vector2(0.7f, 0.6f));
errorPanel.SetActive(false);
var errorTxt = CreateTMPText(errorPanel.transform, "ErrorText", "", 16, FontStyles.Normal, TextAlignmentOptions.Center);
SetAnchors(errorTxt.GetComponent<RectTransform>(), new Vector2(0.05f, 0.1f), new Vector2(0.95f, 0.9f));
// Wire up TrainingController
var controllerGo = new GameObject("TrainingController");
controllerGo.transform.SetParent(canvasGo.transform, false);
var ctrl = controllerGo.AddComponent<CommanderAILab.UI.TrainingController>();
SetPrivateField(ctrl, "backButton", backBtn.GetComponent<Button>());
SetPrivateField(ctrl, "cardCountText", cardCountTxt.GetComponent<TextMeshProUGUI>());
SetPrivateField(ctrl, "sessionCountText", sessionCountTxt.GetComponent<TextMeshProUGUI>());
SetPrivateField(ctrl, "modelVersionText", modelVerTxt.GetComponent<TextMeshProUGUI>());
SetPrivateField(ctrl, "lastTrainedText", lastTrainedTxt.GetComponent<TextMeshProUGUI>());
SetPrivateField(ctrl, "startTrainingButton", startBtn.GetComponent<Button>());
SetPrivateField(ctrl, "stopTrainingButton", stopBtn.GetComponent<Button>());
SetPrivateField(ctrl, "exportModelButton", exportBtn.GetComponent<Button>());
SetPrivateField(ctrl, "statusText", statusTxt.GetComponent<TextMeshProUGUI>());
SetPrivateField(ctrl, "accuracyChartRect", accChart.GetComponent<RectTransform>());
SetPrivateField(ctrl, "lossChartRect", lossChart.GetComponent<RectTransform>());
SetPrivateField(ctrl, "accuracyLabel", accLabel.GetComponent<TextMeshProUGUI>());
SetPrivateField(ctrl, "lossLabel", lossLabel.GetComponent<TextMeshProUGUI>());
SetPrivateField(ctrl, "historyParent", histContent.transform);
SetPrivateField(ctrl, "logText", logText);
SetPrivateField(ctrl, "logScrollRect", logScroll);
SetPrivateField(ctrl, "errorPanel", errorPanel);
SetPrivateField(ctrl, "errorText", errorTxt.GetComponent<TextMeshProUGUI>());
// -- Save --
if (!AssetDatabase.IsValidFolder("Assets/Scenes"))
AssetDatabase.CreateFolder("Assets", "Scenes");
string path = "Assets/Scenes/Training.unity";
EditorSceneManager.SaveScene(scene, path);
var existing = new System.Collections.Generic.List<EditorBuildSettingsScene>(EditorBuildSettings.scenes);
if (!existing.Any(s => s.path == path))
existing.Add(new EditorBuildSettingsScene(path, true));
EditorBuildSettings.scenes = existing.ToArray();
UnityEngine.Debug.Log("[CreateTrainingScene] Training scene created and saved to " + path);
}
// ── Helpers ───────────────────────────────────────────────────────────
static GameObject CreatePanel(Transform parent, string name, Color color, bool stretch = false)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = color;
var rt = go.GetComponent<RectTransform>();
if (stretch)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = rt.offsetMax = Vector2.zero;
}
return go;
}
static GameObject CreateTMPText(Transform parent, string name, string text, int size,
FontStyles style, TextAlignmentOptions align)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var tmp = go.AddComponent<TextMeshProUGUI>();
tmp.text = text;
tmp.fontSize = size;
tmp.fontStyle = style;
tmp.alignment = align;
tmp.color = Color.white;
return go;
}
static GameObject CreateTMPButton(Transform parent, string name, string label, Color bgColor)
{
var go = new GameObject(name);
go.transform.SetParent(parent, false);
var img = go.AddComponent<Image>();
img.color = bgColor;
var btn = go.AddComponent<Button>();
var colors = btn.colors;
colors.highlightedColor = bgColor * 1.3f;
colors.pressedColor = bgColor * 0.7f;
btn.colors = colors;
var txtGo = new GameObject("Text");
txtGo.transform.SetParent(go.transform, false);
var tmp = txtGo.AddComponent<TextMeshProUGUI>();
tmp.text = label;
tmp.fontSize = 16;
tmp.alignment = TextAlignmentOptions.Center;
tmp.color = Color.white;
var txtRT = txtGo.GetComponent<RectTransform>();
txtRT.anchorMin = Vector2.zero;
txtRT.anchorMax = Vector2.one;
txtRT.offsetMin = txtRT.offsetMax = Vector2.zero;
return go;
}
static void SetAnchors(RectTransform rt, Vector2 min, Vector2 max)
{
rt.anchorMin = min;
rt.anchorMax = max;
rt.offsetMin = rt.offsetMax = Vector2.zero;
}
static void SetPrivateField(object obj, string fieldName, object value)
{
var field = obj.GetType().GetField(fieldName,
BindingFlags.NonPublic | BindingFlags.Instance);
field?.SetValue(obj, value);
}
}
#endif
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,40 @@
var FileUploadPlugin = {
_objectName: null,
_methodName: null,
_fileInput: null,
WebGLFileUploadInit: function (objectNamePtr, methodNamePtr) {
this._objectName = UTF8ToString(objectNamePtr);
this._methodName = UTF8ToString(methodNamePtr);
if (!this._fileInput) {
this._fileInput = document.createElement('input');
this._fileInput.type = 'file';
this._fileInput.accept = 'image/*';
this._fileInput.style.display = 'none';
document.body.appendChild(this._fileInput);
var self = this;
this._fileInput.addEventListener('change', function (e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function (evt) {
var base64 = evt.target.result.split(',')[1];
SendMessage(self._objectName, self._methodName, base64);
};
reader.readAsDataURL(file);
e.target.value = '';
});
}
},
WebGLFileUploadClick: function () {
if (this._fileInput) {
this._fileInput.click();
}
}
};
mergeInto(LibraryManager.library, FileUploadPlugin);
@@ -0,0 +1,38 @@
var WebGLInput = {
GetURLParam: function(keyPtr) {
var key = UTF8ToString(keyPtr);
var params = new URLSearchParams(window.location.search);
var value = params.get(key) || '';
var bufferSize = lengthBytesUTF8(value) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(value, buffer, bufferSize);
return buffer;
},
OpenFileDialog: function(acceptPtr, callbackObjPtr, callbackMethodPtr) {
var accept = UTF8ToString(acceptPtr);
var callbackObj = UTF8ToString(callbackObjPtr);
var callbackMethod = UTF8ToString(callbackMethodPtr);
var input = document.createElement('input');
input.type = 'file';
input.accept = accept;
input.onchange = function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
var base64 = ev.target.result.split(',')[1];
SendMessage(callbackObj, callbackMethod, base64);
};
reader.readAsDataURL(file);
};
input.click();
},
SetLoadingProgress: function(progress) {
var bar = document.getElementById('unity-progress-bar-full');
if (bar) bar.style.width = (progress * 100) + '%';
}
};
mergeInto(LibraryManager.library, WebGLInput);
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
# This file keeps the folder in git
@@ -0,0 +1 @@
@@ -0,0 +1,289 @@
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using CommanderAILab.Models;
using CommanderAILab.Services;
namespace CommanderAILab.Animation
{
/// <summary>
/// CardController — manages a single card prefab instance.
/// Handles: card data binding, image loading (async Scryfall),
/// flip animation, hover scale + glow, click raise + detail panel,
/// tooltip overlay, drag support, mobile gestures.
/// Attach to a UI Image-based card prefab.
/// </summary>
public class CardController : MonoBehaviour,
IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler,
IBeginDragHandler, IDragHandler, IEndDragHandler
{
[Header("Card Faces")]
[SerializeField] private Image frontFaceImage;
[SerializeField] private Image backFaceImage;
[Header("Glow / Outline")]
[SerializeField] private Outline glowOutline;
[SerializeField] private Color glowColor = new Color(1f, 0.85f, 0f, 0.9f);
[Header("Tooltip")]
[SerializeField] private GameObject tooltipPanel;
[SerializeField] private TMP_Text tooltipName;
[SerializeField] private TMP_Text tooltipManaCost;
[SerializeField] private TMP_Text tooltipTypeLine;
[Header("Animation Settings")]
[SerializeField] private float hoverScaleMultiplier = 1.12f;
[SerializeField] private float hoverRaiseY = 15f;
[SerializeField] private float flipDuration = 0.35f;
[SerializeField] private float hoverDuration = 0.15f;
[SerializeField] private float clickRaiseY = 40f;
[Header("LOD")]
[SerializeField] private Sprite lowResPlaceholder;
// Public events for parent scenes
public event Action<CardModel> OnCardClicked;
public event Action<CardController> OnDragStarted;
public event Action<CardController, Vector2> OnDragMoved;
public event Action<CardController> OnDragEnded;
private CardModel _card;
private Vector3 _originalScale;
private Vector3 _originalPosition;
private RectTransform _rectTransform;
private bool _isFaceUp = true;
private bool _isFlipping = false;
private bool _isHovered = false;
private bool _isDragging = false;
private Vector2 _dragOffset;
private Canvas _parentCanvas;
// Mobile gesture tracking
private float _pointerDownTime;
private bool _pointerHeld;
private const float LongPressThreshold = 0.4f;
private void Awake()
{
_rectTransform = GetComponent<RectTransform>();
_originalScale = transform.localScale;
_originalPosition = transform.localPosition;
_parentCanvas = GetComponentInParent<Canvas>();
if (tooltipPanel != null) tooltipPanel.SetActive(false);
if (glowOutline != null)
{
glowOutline.effectColor = new Color(glowColor.r, glowColor.g, glowColor.b, 0f);
glowOutline.enabled = false;
}
}
// ── Data Binding ────────────────────────────────────────────────
public void SetCardData(CardModel card)
{
_card = card;
if (tooltipName != null) tooltipName.text = card.name;
if (tooltipManaCost != null) tooltipManaCost.text = card.manaCost;
if (tooltipTypeLine != null) tooltipTypeLine.text = card.typeLine;
// LOD: show low-res placeholder first
if (lowResPlaceholder != null && frontFaceImage != null)
frontFaceImage.sprite = lowResPlaceholder;
// Async load high-res from Scryfall
if (!string.IsNullOrEmpty(card.imageUri))
ImageCache.Instance.GetSprite(card.imageUri, sprite =>
{
if (frontFaceImage != null) frontFaceImage.sprite = sprite;
});
}
public CardModel GetCardData() => _card;
// ── Flip Animation (Y-axis rotation, swap front/back at 90°) ────
public void Flip()
{
if (_isFlipping) return;
StartCoroutine(FlipCoroutine());
}
private IEnumerator FlipCoroutine()
{
_isFlipping = true;
float half = flipDuration * 0.5f;
// First half: rotate to 90°
float t = 0f;
while (t < half)
{
t += Time.deltaTime;
float angle = Mathf.Lerp(0f, 90f, t / half);
transform.localRotation = Quaternion.Euler(0f, angle, 0f);
yield return null;
}
// Swap face visibility
_isFaceUp = !_isFaceUp;
if (frontFaceImage != null) frontFaceImage.gameObject.SetActive(_isFaceUp);
if (backFaceImage != null) backFaceImage.gameObject.SetActive(!_isFaceUp);
// Second half: rotate from 90° back to 0°
t = 0f;
while (t < half)
{
t += Time.deltaTime;
float angle = Mathf.Lerp(90f, 0f, t / half);
transform.localRotation = Quaternion.Euler(0f, angle, 0f);
yield return null;
}
transform.localRotation = Quaternion.identity;
_isFlipping = false;
}
// ── Hover: scale-up + glow outline + raise card ───────────────
public void OnPointerEnter(PointerEventData eventData)
{
if (_isDragging) return;
_isHovered = true;
StopAllCoroutines();
StartCoroutine(AnimateHover(true));
if (tooltipPanel != null) tooltipPanel.SetActive(true);
}
public void OnPointerExit(PointerEventData eventData)
{
if (_isDragging) return;
_isHovered = false;
StopAllCoroutines();
StartCoroutine(AnimateHover(false));
if (tooltipPanel != null) tooltipPanel.SetActive(false);
}
private IEnumerator AnimateHover(bool entering)
{
Vector3 targetScale = entering ? _originalScale * hoverScaleMultiplier : _originalScale;
Vector3 targetPos = entering
? _originalPosition + new Vector3(0, hoverRaiseY, 0)
: _originalPosition;
float targetGlow = entering ? 1f : 0f;
Vector3 startScale = transform.localScale;
Vector3 startPos = transform.localPosition;
float startGlow = glowOutline != null ? glowOutline.effectColor.a : 0f;
if (entering && glowOutline != null) glowOutline.enabled = true;
float t = 0f;
while (t < hoverDuration)
{
t += Time.deltaTime;
float p = t / hoverDuration;
float smooth = p * p * (3f - 2f * p); // smoothstep
transform.localScale = Vector3.Lerp(startScale, targetScale, smooth);
transform.localPosition = Vector3.Lerp(startPos, targetPos, smooth);
if (glowOutline != null)
{
float a = Mathf.Lerp(startGlow, targetGlow, smooth);
glowOutline.effectColor = new Color(glowColor.r, glowColor.g, glowColor.b, a);
}
yield return null;
}
transform.localScale = targetScale;
transform.localPosition = targetPos;
if (!entering && glowOutline != null) glowOutline.enabled = false;
}
// ── Click: raise card + shadow + detail panel ────────────────
public void OnPointerClick(PointerEventData eventData)
{
if (_isDragging) return;
StartCoroutine(ClickRaiseAnimation());
OnCardClicked?.Invoke(_card);
}
private IEnumerator ClickRaiseAnimation()
{
Vector3 raised = transform.localPosition + new Vector3(0, clickRaiseY, 0);
float dur = 0.12f;
float t = 0f;
Vector3 start = transform.localPosition;
while (t < dur)
{
t += Time.deltaTime;
transform.localPosition = Vector3.Lerp(start, raised, t / dur);
yield return null;
}
yield return new WaitForSeconds(0.08f);
t = 0f;
Vector3 target = _isHovered
? _originalPosition + new Vector3(0, hoverRaiseY, 0)
: _originalPosition;
while (t < dur)
{
t += Time.deltaTime;
transform.localPosition = Vector3.Lerp(raised, target, t / dur);
yield return null;
}
}
// ── Drag Support (for DeckBuilder scene) ────────────────────
public void OnBeginDrag(PointerEventData eventData)
{
_isDragging = true;
if (tooltipPanel != null) tooltipPanel.SetActive(false);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_parentCanvas.transform as RectTransform,
eventData.position, eventData.pressEventCamera, out _dragOffset);
_dragOffset = (Vector2)transform.localPosition - _dragOffset;
transform.SetAsLastSibling();
OnDragStarted?.Invoke(this);
}
public void OnDrag(PointerEventData eventData)
{
if (!_isDragging) return;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_parentCanvas.transform as RectTransform,
eventData.position, eventData.pressEventCamera, out Vector2 localPoint);
transform.localPosition = localPoint + _dragOffset;
OnDragMoved?.Invoke(this, eventData.position);
}
public void OnEndDrag(PointerEventData eventData)
{
_isDragging = false;
OnDragEnded?.Invoke(this);
}
// ── Mobile: tap = hover, long-press = detail, drag = move ─────
public void SimulateMobileTap()
{
// Toggle hover state on tap (mobile has no hover)
if (_isHovered)
{
_isHovered = false;
StartCoroutine(AnimateHover(false));
if (tooltipPanel != null) tooltipPanel.SetActive(false);
}
else
{
_isHovered = true;
StartCoroutine(AnimateHover(true));
if (tooltipPanel != null) tooltipPanel.SetActive(true);
}
}
public void SimulateMobileLongPress()
{
OnCardClicked?.Invoke(_card);
}
/// <summary>Reset card position (e.g. after failed drag)</summary>
public void ResetPosition()
{
transform.localPosition = _originalPosition;
transform.localScale = _originalScale;
}
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CommanderAILab.Models
{
/// <summary>
/// Represents a single MTG card from the collection or deck.
/// Maps to the card objects returned by /api/collection and /api/decks.
/// </summary>
[Serializable]
public class CardModel
{
[JsonProperty("id")] public string id;
[JsonProperty("name")] public string name;
[JsonProperty("oracle_id")] public string oracleId;
[JsonProperty("scryfall_id")] public string scryfallId;
[JsonProperty("image_uri")] public string imageUri;
[JsonProperty("mana_cost")] public string manaCost;
[JsonProperty("cmc")] public float cmc;
[JsonProperty("type_line")] public string typeLine;
[JsonProperty("card_type")] public string cardType;
[JsonProperty("colors")] public List<string> colors;
[JsonProperty("color_identity")] public List<string> colorIdentity;
[JsonProperty("set")] public string set;
[JsonProperty("rarity")] public string rarity;
[JsonProperty("oracle_text")] public string oracleText;
[JsonProperty("power")] public string power;
[JsonProperty("toughness")] public string toughness;
[JsonProperty("quantity")] public int quantity;
[JsonProperty("owned_qty")] public int ownedQty;
[JsonProperty("is_proxy")] public bool isProxy;
[JsonProperty("roles")] public List<string> roles;
[JsonProperty("source")] public string source;
public override string ToString() => $"{name} ({manaCost}) [{typeLine}]";
}
}
@@ -0,0 +1,16 @@
using System;
using Newtonsoft.Json;
namespace CommanderAILab.Models
{
/// <summary>Maps to POST /api/coach/chat response.</summary>
[Serializable]
public class CoachResponse
{
[JsonProperty("reply")] public string reply;
[JsonProperty("deck_report")] public string deckReport;
[JsonProperty("suggestions")] public string[] suggestions;
public override string ToString() => reply ?? "(empty coach response)";
}
}
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CommanderAILab.Models
{
/// <summary>Paginated collection list from GET /api/collection.</summary>
[Serializable]
public class CollectionResponse
{
[JsonProperty("cards")] public List<CardModel> cards;
[JsonProperty("total")] public int total;
[JsonProperty("page")] public int page;
[JsonProperty("per_page")] public int perPage;
public override string ToString() => $"Collection: {total} cards (page {page})";
}
}
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CommanderAILab.Models
{
/// <summary>Commander archetype info from /api/lab/meta/commanders.</summary>
[Serializable]
public class CommanderMeta
{
[JsonProperty("name")] public string name;
[JsonProperty("source")] public string source;
[JsonProperty("archetype")] public string archetype;
[JsonProperty("colorIdentity")] public List<string> colorIdentity;
public override string ToString() => $"{name} ({archetype}) [{string.Join(",", colorIdentity ?? new List<string>())}]";
}
}
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CommanderAILab.Models
{
/// <summary>Maps to GeneratedDeckCard from /api/deckgen/v3.</summary>
[Serializable]
public class GeneratedDeckCard
{
[JsonProperty("scryfall_id")] public string scryfallId;
[JsonProperty("name")] public string name;
[JsonProperty("type_line")] public string typeLine;
[JsonProperty("mana_cost")] public string manaCost;
[JsonProperty("cmc")] public float cmc;
[JsonProperty("card_type")] public string cardType;
[JsonProperty("roles")] public List<string> roles;
[JsonProperty("source")] public string source;
[JsonProperty("quantity")] public int quantity;
[JsonProperty("image_url")] public string imageUrl;
[JsonProperty("owned_qty")] public int ownedQty;
[JsonProperty("is_proxy")] public bool isProxy;
public override string ToString() => $"{name} ({manaCost})";
}
/// <summary>Response wrapper for /api/deckgen/v3.</summary>
[Serializable]
public class DeckGenResponse
{
[JsonProperty("cards")] public List<GeneratedDeckCard> cards;
[JsonProperty("commander")] public string commander;
[JsonProperty("strategy")] public string strategy;
public override string ToString() => $"Generated deck: {commander} ({cards?.Count ?? 0} cards)";
}
}
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CommanderAILab.Models
{
[Serializable]
public class DeckModel
{
[JsonProperty("id")] public string id;
[JsonProperty("name")] public string name;
[JsonProperty("commander")] public string commander;
[JsonProperty("colors")] public List<string> colors;
[JsonProperty("cards")] public List<CardModel> cards;
[JsonProperty("card_count")] public int cardCount;
[JsonProperty("created_at")] public string createdAt;
[JsonProperty("updated_at")] public string updatedAt;
public override string ToString() => $"{name} ({commander}) [{cardCount} cards]";
}
}
@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace CommanderAILab.Models
{
/// <summary>Wrapper for the precon list returned by /api/lab/precons.</summary>
[Serializable]
public class PreconListWrapper
{
[JsonProperty("precons")] public List<string> precons;
}
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more