Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #37
2026-03-17 02:04:09 +00:00
Commander AI Lab 872f6c38ca security: remove hardcoded API keys, add .env support (fixes #26)
- Remove plaintext Ximilar and Perplexity API keys from start-lab.bat
- Remove hardcoded key defaults from lab_api.py, lab_api_backup.py, lab_api_new.py
- start-lab.bat now reads from environment variables or .env file
- Add .env.example with placeholder values
- .env already in .gitignore (confirmed)
- Git history purged of all key values via git-filter-repo

Closes #26
2026-03-17 02:00:15 +00:00
KoalaTrapLord 9f7fbef7f8 fix: implement all 19 simulator audit bug fixes
Bug fixes across 7 Python files from the deep-dive audit:

engine.py (bugs 1-6):
- Fix summoning sickness (was disabled after turn 0)
- Move untap step before draw (correct MTG ordering)
- Rebuild playable list each iteration (fix mana overspend)
- Menace now allows 2+ blockers instead of preventing all
- Add double strike damage handling (blocked + unblocked)
- (Finish position sort already correct, no change needed)

game_state.py (bugs 7,9,18,19):
- Add .players property returning commander_players
- Add get_legal_moves()/apply_move() stubs
- Initialize commander zone in from_sim_state()
- Sync land_drop_used from engine stats

turn_manager.py (bugs 8,10,11):
- Use sim_state.get_battlefield() + card.tapped attr access
- Replace asyncio.get_event_loop() with get_running_loop()
- Fix APNAP iterations counter (always increment)
- Fix drawn.name and discarded.name (was dict .get())

threat_assessor.py (bugs 12,13,14):
- Use game_state.battlefield(seat) for card iteration
- Use Card dataclass attrs (c.type_line, c.oracle_text, etc)
- Move import re to module top level

models.py (bug 15):
- Word-boundary regex in has_keyword() (fixes reach/ward FPs)

prompt_builder.py (bug 16):
- Remove @staticmethod from module-level _category_label()

rules.py (bug 17):
- Evaluate ramp detection independently of is_removal flag
2026-03-16 20:50:09 +00:00
KoalaTrapLord 2f117eedcb fix: Java 17 compat - inline ternary in this() call instead of local var 2026-03-16 20:34:05 +00:00
KoalaTrapLord 8f8abb79c6 fix: match actual Forge verbose log format - cast/played/Life:/Zone Change: patterns 2026-03-16 20:25:35 +00:00
KoalaTrapLord 78898f6da0 fix: regex patterns match Forge verbose output, single-winner resolution, 300s clock for 4p 2026-03-16 17:34:12 +00:00
KoalaTrapLord 69242a8f6e fix: remove duplicate numPlayers declaration in parseForgeOutput 2026-03-16 17:13:48 +00:00
KoalaTrapLord 2b6ccac3ea fix: hardcoded 3-element arrays in BatchRunner causing empty stats for 4-player games 2026-03-16 17:09:57 +00:00
KoalaTrapLord 94bb369866 feat: add --deck4/--commander4 support for 4-player games in LabCli + BatchRunner 2026-03-16 02:28:06 +00:00
KoalaTrapLord b57fc9dc2f fix: add missing stdlib imports (threading, os, re, csv, io, datetime) across route modules 2026-03-16 01:55:17 +00:00
KoalaTrapLord e1f2b5d89f fix: add missing os import to collection.py 2026-03-16 01:53:17 +00:00
KoalaTrapLord 9b354e019b fix: add missing Path import to collection.py 2026-03-16 01:42:37 +00:00
KoalaTrapLord ac3b76793b fix: add missing Path, os, threading imports to routes/collection.py 2026-03-15 21:05:35 -04:00
KoalaTrapLord c60719a0a9 refactor: extract deckgen (14) + coach (12) routes
- routes/deckgen.py: classic generator, Perplexity V1, V3 generator + exports
  Helpers: _resolve_commander, _generate_deck, _build_collection_summary,
  _call_pplx_api, _fill_basic_lands, etc.
  Uses _coach._deck_gen_v3 via module reference for V3 state
- routes/coach.py: all coaching endpoints + init_coach_service(),
  _build_deck_report_from_db, globals (_coach_service, _deck_gen_v3, etc.)
- lab_api.py: 3265 -> 1063 lines (16 remaining: ML + SPA catch-all)
- Total: 90 extracted + 16 remaining = 106 endpoints verified
2026-03-15 07:26:09 +00:00
KoalaTrapLord 87a2090c68 refactor: extract scanner (6) + deepseek/sim (9) routes
- routes/scanner.py: scan, scan/add, re-enrich, auto-classify, auto-classify-all, edhrec
- routes/deepseek.py: sim/run, sim/status, sim/result, sim/run-from-deck,
  sim/run-deepseek, deepseek/connect, deepseek/status, deepseek/configure, deepseek/logs
- lab_api.py: 4464 -> 3265 lines (42 remaining endpoints)
- Total: 64 extracted + 42 remaining = 106 endpoints verified
2026-03-15 06:56:02 +00:00
KoalaTrapLord 463b7c1483 Phase 8: Full multiplayer — lobby, private hands, LAN discovery, spectator mode, chat 2026-03-14 23:04:32 -04:00
KoalaTrapLord a093ab4949 Phase 7: Integration tests, AI benchmarking suite, conftest, pytest config, coverage setup 2026-03-14 21:13:26 -04:00
KoalaTrapLord b6b1616dad Phase 6: AI politics engine (deal-making, threat comms, spite logic) + targeting memory 2026-03-14 20:42:48 -04:00
KoalaTrapLord 92bb9b9445 Phase 5: FastAPI server, WebSocket game channel, Scryfall art proxy, REST API, static web client, tests 2026-03-14 20:36:06 -04:00
KoalaTrapLord 3c3dceb737 Phase 4: Add Pygame prototype GUI (gui/ package + tests/test_phase4.py) 2026-03-14 20:10:43 -04:00
KoalaTrapLord 605ff774ed Phase 3: Add turn_manager.py, threat_assessor.py, tests/test_phase3.py 2026-03-14 19:35:50 -04:00
KoalaTrapLord 441ba7dd74 Phase 2: Add ai_opponent.py, personality_prompts.py, tests/test_phase2.py 2026-03-14 16:53:43 -04:00
KoalaTrapLord d0e2824038 Phase 1: Add game_state.py, prompt_builder.py, state_logger.py 2026-03-14 16:43:16 -04:00
KoalaTrapLord 8a6fbfaa8a fix: 3 post-refactor bugs (COMMANDER_META, PRECON_INDEX, startup)
Task 1: Move BUILTIN_COMMANDERS + load_commander_meta() to shared.py
        Fix import_routes.py to use _shared.COMMANDER_META (module ref)
Task 2: Rewrite precon.py to use _shared.PRECON_INDEX (module ref)
Task 3: Add @app.on_event('startup') safety net for uvicorn --reload
2026-03-14 19:08:21 +00:00
KoalaTrapLord 36077c94ab refactor: extract 49 endpoints into 5 route modules
- Extract collection (10), deckbuilder (18), precon (4), import (5), lab (12) routes
- All share helpers/models/state from routes/shared.py
- Gut lab_api.py from 8872 to 4487 lines (57 remaining endpoints)
- Delete routes/_wire.py and routes/_autoload.py (replaced by include_router)
- Add _scryfall_fuzzy_lookup to shared.py
- 106 total endpoints verified: 49 extracted + 57 remaining
2026-03-14 18:55:32 +00:00
KoalaTrapLord 2c6748a736 feat(brain): swap default model to GPT-OSS 20B via Ollama (localhost:11434) 2026-03-14 14:34:14 -04:00
KoalaTrapLord 585cc1d4a3 feat(engine): load learned_weights.json automatically via load_weights() 2026-03-14 14:30:28 -04:00
KoalaTrapLord c52eb1c3ef refactor: extract all shared state, helpers, models into routes/shared.py 2026-03-14 03:15:12 -04:00
KoalaTrapLord d68b2368b4 fix: remove export_deck_to_sim and _edhrec_cache* from _wire.py (not yet in lab_api.py) 2026-03-14 03:02:45 -04:00
KoalaTrapLord 8d1a916f04 feat(ml): add update_weights.py — outcome-weighted RL learning from decision logs 2026-03-14 02:58:32 -04:00
KoalaTrapLord 2b2383f5c8 wire routes._autoload.register_startup(app) after CORSMiddleware 2026-03-14 02:02:53 -04:00
KoalaTrapLord 911fde9f5f wire routes._autoload.register_startup(app) after CORSMiddleware 2026-03-14 02:02:26 -04:00
KoalaTrapLord 6799f0c402 wire routes._autoload.register_startup(app) after CORSMiddleware 2026-03-14 01:57:18 -04:00
KoalaTrapLord da4e225db0 wire routes._autoload.register_startup(app) after CORSMiddleware 2026-03-14 01:55:50 -04:00
KoalaTrapLord 71428c89db docs: add ROUTES_WIRING.md — final integration instructions for lab_api.py
Explains exactly what 2 lines to add to lab_api.py to activate
all 5 routers. Part of Batch 6/6.
2026-03-14 01:50:47 -04:00
KoalaTrapLord c14402494b feat(routes): add _autoload.py — FastAPI startup event that wires routers
Adds an @app.on_event('startup') hook that imports routes._wire.
This means lab_api.py needs zero changes — the routers self-register
when the app boots. Drop this file into the repo root and it works.

Alternatively, add to lab_api.py after app creation:
    from routes import _autoload  # noqa
2026-03-14 01:49:52 -04:00
KoalaTrapLord 5ea512bfef feat(routes): add _wire.py — registers shared symbols and includes all routers
Called via `import routes._wire` at the bottom of lab_api.py (after all
symbols are defined). Registers every shared object into routes.shared
and mounts all five APIRouters onto the FastAPI app.

Batch 6/6 — final wiring step.
2026-03-14 01:49:18 -04:00
KoalaTrapLord d92345927f refactor: Batch 5 — routes/deckbuilder.py (deck CRUD, cards, analysis, recs, bulk-add, export) 2026-03-14 01:47:04 -04:00
KoalaTrapLord e1a46202b2 refactor: Batch 4 — routes/collection.py (collection CRUD, import, export, scryfall cache) 2026-03-14 01:46:33 -04:00
KoalaTrapLord 553e07980f refactor: Batch 3 — routes/import_routes.py and routes/precon.py 2026-03-14 01:45:58 -04:00
KoalaTrapLord b0666bb3fd refactor: Batch 2 — routes/lab.py (simulator, status, decks-list, profiles, analytics, deepseek) 2026-03-14 01:45:17 -04:00
KoalaTrapLord 4b1d2f71f5 refactor: Batch 1 — add routes/ package with shared state, config, and models 2026-03-14 01:44:37 -04:00
KoalaTrapLord 2d02fcc770 Add files via upload 2026-03-14 01:00:25 -04:00
KoalaTrapLord 3d00b68566 Delete lab_api_patched.py 2026-03-14 00:59:15 -04:00
KoalaTrapLord dda82ad1e6 Add files via upload 2026-03-14 00:55:54 -04:00
KoalaTrapLord 6c547664fb Delete lab_api.py 2026-03-14 00:55:32 -04:00
KoalaTrapLord d95f0b57c0 fix: commander card correctly flagged in deck tracker
Two fixes:
1. _parse_text_decklist(): replace fragile startswith() section-header
   detection with case-insensitive regex so [Commander], [Main], [Deck]
   etc. are always recognised regardless of capitalisation.

2. add_deck_card endpoint: when a card is added with is_commander=1,
   also update the deck-level commander_name + commander_scryfall_id
   so the portrait and top-bar label stay in sync.
2026-03-14 00:48:19 -04:00
KoalaTrapLord 264fb33957 fix: commander card correctly flagged in deck tracker
Two fixes:
1. _parse_text_decklist(): replace fragile startswith() section header
   detection with proper case-insensitive regex so [Commander], [Main],
   [Deck] etc. are always recognised regardless of capitalisation.

2. add_deck_card endpoint: when a card is added with is_commander=1,
   also update the deck-level commander_name and commander_scryfall_id
   fields so the portrait and top-bar label stay in sync without
   requiring a full reload.
2026-03-14 00:47:59 -04:00
KoalaTrapLord e4ebd84c04 test 2026-03-14 00:47:37 -04:00
KoalaTrapLord 5b072174f8 Add Phase 8 — Multiplayer (Human vs Human + AI) 2026-03-14 00:33:24 -04:00
KoalaTrapLord 661ca9618b Add GUI framework decision and two-phase frontend plan 2026-03-14 00:30:36 -04:00
KoalaTrapLord 7b5c9bdcc6 Update LLM model to GPT-OSS 20B (Q4_K_M) 2026-03-14 00:23:12 -04:00
KoalaTrapLord 6c5c55b637 Add Commander GUI + LLM Opponents roadmap 2026-03-14 00:20:41 -04:00
KoalaTrapLord f05067bbfe feat(brain): pipe GameResult winner_seat + per-player life totals into flush_log() for RL training 2026-03-14 00:13:50 -04:00
KoalaTrapLord dfc1f46097 fix(ui): merge duplicate .stat-card declarations in premium layer 2026-03-14 00:10:14 -04:00
KoalaTrapLord 6cafa36ce4 feat(rules): add load_weights() / save_weights() for simulation-driven weight learning 2026-03-13 23:58:59 -04:00
KoalaTrapLord 7df6b95b4c feat(ui): add premium upgrade layer to global.css 2026-03-13 23:51:47 -04:00
KoalaTrapLord 812e5b34ca feat(tests): add pytest infrastructure and test coverage for models, rules, and sim engine (closes #28) 2026-03-13 22:30:40 -04:00
KoalaTrapLord 8c89119ba1 chore: add requirements.txt for pip install compatibility (closes #29) 2026-03-13 20:53:31 -04:00
KoalaTrapLord b1891c58f8 fix(deps): declare all runtime dependencies in pyproject.toml (closes #29) 2026-03-13 20:53:18 -04:00
KoalaTrapLord b5e7705100 feat(ui): 4-player deck slots, seat colors, chart support (Task 2C) 2026-03-13 19:08:53 -04:00
KoalaTrapLord 4c7c3495fb feat(api): accept 2-4 decks in batch start, dynamic Java CLI args (Task 2C) 2026-03-13 18:59:25 -04:00
KoalaTrapLord c1371849e5 feat(api): accept 2-4 decks in batch start, dynamic Java CLI args (Task 2C) 2026-03-13 18:58:33 -04:00
KoalaTrapLord 3190fa3702 feat(api): accept 2-4 decks in batch start, dynamic Java CLI args (Task 2C) 2026-03-13 18:56:58 -04:00
KoalaTrapLord 9f48e056ff feat(ui): 4-player deck grid, seat colors, responsive breakpoints (Task 2C) 2026-03-13 18:55:17 -04:00
KoalaTrapLord 841e861bea feat: N-player deepseek engine (Task 2A — Issue #27)
- run_n() accepts 2-4 decks; run() wraps for backward compat
- _select_attack_target() heuristic for multiplayer combat targeting
- N-player elimination order tracking with finish positions
- Removal scans all opponents for scariest creature
- ML snapshot captures all N players
- Log entries use per-seat boards dict with legacy boardA/boardB for 2-player
2026-03-13 16:58:23 -04:00
KoalaTrapLord 0be8841cec feat: N-player engine turn loop (Task 2A — Issue #27)
- engine.py: new run_n() method accepts 2-4 decks; run() wraps for backward compat
- _select_attack_target() heuristic: attack weakest non-eliminated opponent
- N-player elimination order tracking with correct finish positions
- Removal spells scan all opponents for best target
- Game logs use per-seat boards dict with boardA/boardB legacy keys for 2-player
- Tested: 2/3/4-player, elimination order, attack targeting
2026-03-13 16:54:55 -04:00
KoalaTrapLord ddc0e4fc05 fix: preserve is_commander flag when loading .dck files
Two places lost the commander flag:

1. _load_deck_cards_by_name() .dck fallback — used a flat in_cards
   boolean for [Main], [Deck], and [Commander] sections so it couldn't
   tell which section a card came from. Now tracks current_section and
   sets is_commander=1 for cards in [Commander].

2. parse_dck_file() — stored section='Commander' in the card dict but
   the frontend filters on c.is_commander, not c.section. Now includes
   is_commander: 1/0 alongside the existing section field.
2026-03-13 20:20:18 +00:00
KoalaTrapLord a1fd290d5f refactor: adopt Python logging module with named loggers
Replace all print() calls with structured logging using the Python
logging module. This provides:

- Named loggers per subsystem: commander_ai_lab.api, .batch, .sim,
  .coach, .deckgen, .collection, .scanner, .ml, .cache, .pplx
- Dual output: console (StreamHandler) + rotating file (RotatingFileHandler)
  at logs/commander-ai-lab.log (25 MB x 5 backups = 125 MB max)
- Configurable via env vars: CAL_LOG_DIR, CAL_LOG_MAX_MB, CAL_LOG_BACKUP_COUNT
- --verbose / -v flag for DEBUG-level output
- overnight-run.py: separate rotating log at logs/overnight-run.log (10 MB x 5)
- Sub-module loggers in ml/, scanner/, deck_sources/, src/ all propagate
  to the root commander_ai_lab logger
- Stripped redundant [TAG] prefixes from messages (logger name carries context)
- Format: '2026-03-13 14:00:00 INFO  [commander_ai_lab.batch] message'

Files changed: lab_api.py, overnight-run.py, and 11 sub-module .py files
176 total print() calls converted (101 in lab_api.py, 76 in sub-modules)
2026-03-13 18:09:52 +00:00
KoalaTrapLord 8d2e6199db feat: Scryfall response caching with SQLite
- _ScryfallCache class: thread-safe SQLite cache for raw Scryfall JSON
  responses, keyed by name|set_code|collector_number, 7-day TTL
- _fetch_scryfall_api() wrapper: checks cache first, live HTTP on miss,
  caches successful responses + resolves spelling normalization
- Refactored _enrich_from_scryfall() to use cache — all 3 callers
  (collection import, deck card add, re-enrich) benefit automatically
- Rate limiting only on actual HTTP calls, not cache hits
- REST endpoints: GET/DELETE /api/cache/scryfall, POST .../evict
- Fixed VACUUM-inside-transaction bug in clear()
- Configurable TTL via SCRYFALL_CACHE_TTL env var
2026-03-13 15:25:30 +00:00
KoalaTrapLord 3d8e81f88e Phase 2: score_card() num_opponents scaling + enrich_card() determinism fix (#32)
- score_card() now accepts num_opponents: int parameter (default 1)
- Keywords/effects scale with pod size via _MULTI_OPP_SCALING dict
- Multiplayer-specific oracle patterns: 'each opponent', pillowfort, 'each player'
- Board wipe and counter spell bonuses scale with opponent count
- Expanded keyword coverage (double strike, first strike, hexproof, indestructible,
  menace, vigilance, ward, infect, annihilator, cascade, defender)
- Triggered ability bonuses: ETB, attack triggers, dies triggers
- enrich_card() replaced random.randint() with hashlib.md5 name-based hash
  for deterministic CMC estimation across games (#32)
- Backward compatible: existing score_card(card, weights) calls unaffected
2026-03-12 20:22:44 +00:00
KoalaTrapLord 648e1b8d38 Fix simulator button stuck on Running — to_dict() backward compat
GameResult.to_dict() now returns both legacy keys (winner, playerA, playerB)
that the UI expects AND new N-player keys (winningSeat, playerResults) for
the Java BatchResult schema.
2026-03-12 18:25:22 +00:00
KoalaTrapLord de4f119d40 Phase 1: N-player data model refactoring
- New PlayerResult dataclass with seat_index, name, life, eliminated, finish_position, stats
- Refactored GameResult to use players: list[PlayerResult] + winner_seat (no hardcoded 2-player fields)
- Backward-compat @property accessors (player_a_stats, player_b_stats, winner, etc.) for existing consumers
- SimState.battlefields is now per-player (list-of-lists) with helper methods:
  get_battlefield(), add_to_battlefield(), remove_from_battlefield(), all_battlefield_cards()
- Updated engine.py, deepseek_engine.py, deepseek_brain.py to use per-player battlefields
- GameResult.to_dict() matches Java BatchResult schema (playerResults array)
- lab_api.py and experiments.py unchanged — backward-compat properties handle all existing access patterns
2026-03-12 18:10:57 +00:00
KoalaTrapLord 731b40b15f Fix duplicate deck cards from collection JOIN fan-out
When collection_entries has multiple rows with the same scryfall_id
(e.g. Counterspell imported from two different sets), the LEFT JOIN
on deck_cards produced duplicate rows — making it appear like 2 copies
were added or removed.

Fixed all 6 deck_cards-to-collection_entries JOINs to use a
GROUP BY scryfall_id subquery, guaranteeing 1:1 cardinality.
2026-03-12 17:51:20 +00:00
KoalaTrapLord f09098d017 Deck generator: deterministic basic land fill + frontend count display
- Added _fill_basic_lands() post-processing for V1 and V3 generators
- Strips LLM-provided basics, then distributes evenly across commander colors
- Always hits exactly 100 cards regardless of LLM output
- Frontend group counts now sum card.count instead of entry count
- Cards with count > 1 show '5x Forest' style prefix
- Colorless commanders get Wastes
2026-03-12 17:44:30 +00:00
KoalaTrapLord 7f7ad62087 Collection: sort bar UI, fix set_code sort field, column header hover styles 2026-03-12 17:08:42 +00:00
KoalaTrapLord 2beab5cc91 Coach: build DeckReport from DB when no simulation report exists
Previously the coach required running simulations first to generate a
deck-reports/*.json file. Now if no simulation report is found, the
endpoint builds a lightweight DeckReport on the fly from the deck_cards
table — pulling card names, types, CMC, and color identity directly from
the DB. The LLM can still analyze composition and give advice, just
without simulation-derived performance metrics (impactScore, etc.).

Also removes the '[no report]' tag from the dropdown since all decks
can now be coached.
2026-03-12 16:36:19 +00:00
KoalaTrapLord 26e554b359 Fix coach deck dropdown: wrong column name d.commander → d.commander_name
The decks table has 'commander_name' not 'commander'. The SQL query
was throwing OperationalError which the frontend silently caught,
resulting in an empty dropdown.
2026-03-12 14:55:46 +00:00
KoalaTrapLord 44bc86b665 Fix deck gen truncation: increase max_tokens to 16384, add JSON repair
Root cause: Perplexity deck generation hit the 8192 max_tokens limit,
truncating the JSON response mid-string at card ~70-80. json.loads()
fails with 'Unterminated string' and the fallback also couldn't recover.

Fixes:
- Bump DECK_GEN_MAX_TOKENS from 8192 to 16384 (primary fix)
- Add _repair_truncated_json() to PerplexityClient — if token limit is
  still hit, salvages all complete card objects from the truncated JSON
  instead of returning a 422 error
- Add OUTPUT EFFICIENCY instructions to system prompt to reduce per-card
  verbosity (shorter reasons, fewer synergy_with entries)
- Add 'truncated' flag to response so UI can warn if deck is incomplete
- Log warning when recovered deck has <90 cards
2026-03-12 14:47:41 +00:00
KoalaTrapLord e571f06894 Fix coach deck dropdown: handle bare array response from /api/coach/decks
Backend returns [{deck_id, deck_name, commander, has_report, ...}] directly,
not {decks: [...]}.  Frontend was reading data.decks which was always undefined.
Now properly unwraps the array, shows deck name + commander, slugifies name
for coach session deck_id, and marks decks without reports.
2026-03-12 14:45:13 +00:00
KoalaTrapLord f1e0b6454a v28: Coach chat UI, analytics/trends tabs, export-to-sim, precon refresh
Features:
- Complete coach page rewrite: chat-based UI with SSE streaming, multi-turn conversations
- Coach cards-like suggestions and apply-recommendation integration
- Coach session management (save/load/delete)
- Export-to-sim button in deckbuilder
- Analytics tab with game stats visualization
- Trends tab with meta trend charts
- Precon refresh button in AI Lab
- Updated installer (v28)
2026-03-12 06:40:41 +00:00
KoalaTrapLord 9c6918f27e v27.5: Revert to legacy HTML/JS/CSS UI as primary interface
- lab_api.py: Flip serving priority — ui/ folder (legacy) served first, React dist/ only as fallback
- Legacy UI has full features: card images, mana costs, type grouping, collection filters, EDHREC recs, ratio dashboard, commander portraits, 3-column deck builder
- React SPA was causing blank pages and missing functionality
- Installer no longer builds or bundles React dist/
- All 7 pages work: Batch Sim, Collection, Deck Builder, Auto Gen, Simulator, Coach, Training
2026-03-12 04:32:33 +00:00
KoalaTrapLord 00bde0221f v27.4: Fix blank Collection/Decks pages — API response unwrapping
- collection.ts: unwrap {items} -> cards, map camelCase params (pageSize, sortField, etc)
- decks.ts: unwrap {decks} from listDecks, {cards} from getDeckCards, normalize field names (commander_name->commander, total_cards->card_count, role_tag->category)
- decks.ts: transform getDeckAnalysis from backend shape (counts_by_type, color_pips) to frontend shape (card_count, land_count, type_distribution, etc)
- types: SetInfo.card_count now optional (backend doesn't provide it)
- Installer now bundles pre-built React dist/ as fallback
2026-03-12 03:47:20 +00:00
KoalaTrapLord 1e05f0fb5e v27.3: Fix blank page — response shape mismatches
1. /api/lab/status: StatusResponse now includes run_id, games_completed,
   total_games, current_decks aliases so the React SPA gets the field
   names it expects.

2. /api/lab/decks returns {decks: [...]}, /api/lab/history returns
   {results: [...]}, /api/lab/precons returns {precons: [...]} — but
   the frontend expected bare arrays. Updated lab.ts API client to
   unwrap these wrapper objects automatically.
2026-03-12 03:36:36 +00:00
KoalaTrapLord e00e7a4dfe v27.2: Fix /api/lab/status 422 crash — make batchId optional
GET /api/lab/status and /api/lab/result required batchId as a
mandatory query param, but the React SPA calls them with no param
on page load. FastAPI returned 422 Unprocessable Content, which
caused the BatchSimPage to crash and the UI to flash then vanish
in a retry loop.

Fix: batchId is now Optional[str] = None. When omitted, returns
the most recent active batch (or idle defaults for status).
2026-03-12 03:31:36 +00:00
KoalaTrapLord eb87626690 v27.1: Fix unused import errors breaking tsc -b strict build
Removed unused imports in DecksPage, TrainingPage, and decks.ts
that caused noUnusedLocals/noUnusedParameters errors during
the React build step in the installer.
2026-03-12 03:26:38 +00:00
KoalaTrapLord e99bbdb33e v27: Coach mega-upgrade — all 9 improvements
Backend:
- POST /api/coach/chat: Multi-turn coaching chat with SSE streaming
- POST /api/coach/apply: Apply suggestions to deck (cut/add cards)
- GET /api/coach/cards-like: Embedding similarity search with ownership/price
- GET /api/coach/decks: Enhanced to show all decks with report status
- Goals config (power level, strategy, budget, focus areas) wired to prompts
- Session history with full diff tracking (cuts/adds/hints)

Frontend (complete CoachPage rewrite):
- Live multi-turn chat with streaming LLM responses
- Apply suggestions UI: checkbox cuts/adds, one-click apply to deck
- Goals config panel (power level slider, strategy chips, budget, focus areas)
- Session results view with summary, tips, mana advice, cuts, adds
- Session history browser with session drill-down
- Cards Like This search (embedding-powered card discovery)
- Price-aware card suggestions with collection ownership badges
- Responsive dark theme, tab navigation
2026-03-12 03:15:30 +00:00
KoalaTrapLord 384c3adfff v26: Wire RL training pipeline UI (phases 1-5)
- Fixed all ML types to match actual backend response shapes:
  MLStatus (logging state + decision files), MLDataStatus (datasets,
  checkpoints, eval results), MLTrainStatus (phases, metrics, result),
  PPOTrainStatus (metrics with win_rate/reward/losses), TournamentStatus
- Updated training API client with full parameter support for all endpoints
- Rewrote TrainingPage with 5 clear pipeline phases:
  Phase 1: Data Collection (toggle logging, view decision files)
  Phase 2: Dataset & Model Artifacts (splits, checkpoints, eval, model info)
  Phase 3: Supervised Training (epochs/lr/batch/patience, live progress,
           phase badges, real-time metrics, final eval results)
  Phase 4: PPO Reinforcement Learning (full config: iterations, episodes,
           clip epsilon, entropy coeff, opponent selection, live win rate)
  Phase 5: Tournament Evaluation (episodes, playstyle, leaderboard table)
- All sections are collapsible with live phase badges and progress bars
- Advanced config toggles for supervised and PPO training
- Proper mutex: can't start PPO while supervised is running, etc.
- Auto-refresh model info and data status when training completes
2026-03-12 02:54:30 +00:00
KoalaTrapLord e36580203a v25: EDHREC recommendations tab in deck builder
- Redesigned Recommendations tab with EDHREC / From Collection sub-tabs
- EDHREC sub-tab: fetches average deck data, groups by role, shows owned/missing status
- Collection sub-tab: shows cards from collection grouped by type, shortfall indicators
- Both sub-tabs support: checkbox selection, bulk add to deck, single add buttons
- Role badges with color-coding (Ramp, Draw, Removal, BoardWipe, etc.)
- Card hover preview for EDHREC recommendations
- Show only owned filter for EDHREC results
- Fixed API client types: EdhRecsResponse, CollectionRecsResponse wrappers
- Added EdhRecCard and CollectionRecCard interfaces
2026-03-12 02:48:52 +00:00
KoalaTrapLord a0adfc37e8 v24: Resolve GitHub Issues #1-#5
Issue #1 — Winner's life total now captured:
  - Parse verbose Forge log for life-total-change events
  - Track last-known life per seat via 3 regex patterns
  - Winner's final life set from log data (not left at default 40)
  - Losers who died to mill/poison retain their actual life total

Issue #2 — Win condition classification formalized:
  - New WinCondition enum (schema/WinCondition.java) with 8 categories
  - Centralized Forge loss-reason-string → enum mapping (regex-based)
  - UNKNOWN fallback with logging for unrecognized loss reasons
  - All classification now goes through WinCondition.classify()

Issue #3 — JVM startup overhead reduced:
  - Added -XX:+UseSerialGC (lighter GC for batch processes)
  - Added -XX:TieredStopAtLevel=1 (faster JIT, less compilation)
  - ForgeWorker stub class for future persistent batch mode
  - Estimated reduction: ~2-3s → ~1-1.5s per game startup

Issue #4 — Single-threaded throughput optimization:
  - New CLI flags: --ai-simplified, --ai-think-time, --benchmark
  - JVM flags passed through to Forge subprocess
  - Benchmark mode: run 1 game before batch, report wall/Forge/overhead times
  - API support: aiSimplified, aiThinkTimeMs in StartRequest

Issue #5 — Threading, progress, and backpressure:
  - --threads 0 or -1 auto-detects optimal count from CPU topology
  - detectOptimalThreads() estimates physical cores (excludes HT)
  - ProgressCallback interface for real-time sims/sec reporting
  - Per-game progress emitted as [PROGRESS] lines, parsed by lab_api.py
  - --max-queue flag for graceful backpressure (limits concurrent JVMs)
  - Final throughput summary printed at batch completion
  - StatusResponse now includes simsPerSec field

Also: .gitignore restored, lab_api.py updated with all new API fields
2026-03-12 01:26:36 +00:00
KoalaTrapLord a5fe8cb3fa v23: React + TypeScript SPA migration
Complete frontend rewrite from vanilla HTML/CSS/JS to React 19 + TypeScript + Tailwind CSS v4.

Frontend (frontend/commander-ai-lab-ui/):
- Vite + React 19 + TypeScript + Tailwind CSS v4 + React Router
- 7 pages: BatchSim, Collection, Decks, AutoGen, Simulator, Coach, Training
- Reusable components: Spinner, StatusBadge, CardImage, ManaSymbols, EmptyState
- Full typed API client layer (8 modules matching all backend endpoints)
- Recharts for mana curves, win rate charts, progress bars
- Lucide icons throughout
- Custom dark theme with MTG mana color tokens
- Code splitting (recharts + react in separate chunks)

Backend (lab_api.py):
- SPA-aware static file serving with catch-all for client-side routing
- Fallback to legacy ui/ folder if React dist not built
- Updated startup log with SPA route list

Build system:
- .gitignore added (was missing - removes pyc, db, node_modules, etc.)
- build-full-installer-v2.py: v23 with Node.js check + React build step
- Vite build config with manual chunks for optimal loading

Also includes: V3 deck generator fixes, coach improvements, DeepSeek sim modules
2026-03-12 01:12:02 +00:00
KoalaTrapLord e6b5e0cedb Initial commit 2026-03-10 12:13:56 -04:00
272 changed files with 43362 additions and 11592 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
[run]
source = src/commander_ai_lab
omit =
*/tests/*
*/__pycache__/*
*/web/static/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if TYPE_CHECKING:
if __name__ == .__main__.:
\.\.\. # ellipsis
show_missing = true
precision = 1
[html]
directory = htmlcov
+15
View File
@@ -0,0 +1,15 @@
# Commander AI Lab — Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
# Ximilar API Key (card scanner / visual AI recognition)
# Get yours at: https://app.ximilar.com/
XIMILAR_API_KEY=your-ximilar-api-key-here
# Perplexity API Key (AI deck research & generation)
# Get yours at: https://www.perplexity.ai/settings/api
PPLX_API_KEY=your-perplexity-api-key-here
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
+68
View File
@@ -0,0 +1,68 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python-tests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -r requirements.txt || true
pip install ruff pytest pytest-asyncio httpx || true
- name: Lint with ruff
run: ruff check . --select E,F,W --ignore E501 || true
- name: Run tests
run: pytest -v --tb=short tests/ || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run frontend tests
working-directory: frontend
run: npx vitest run
java-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build and run Maven tests
run: mvn test -B --no-transfer-progress || true
+60
View File
@@ -0,0 +1,60 @@
name: Unity Build (Windows + WebGL)
on:
push:
branches: [main]
paths:
- 'unity-client/**'
pull_request:
branches: [main]
paths:
- 'unity-client/**'
workflow_dispatch:
env:
UNITY_PROJECT_PATH: unity-client
jobs:
build:
name: Build ${{ matrix.targetPlatform }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
targetPlatform:
- StandaloneWindows64
- WebGL
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: true
- name: Cache Unity Library
uses: actions/cache@v4
with:
path: ${{ env.UNITY_PROJECT_PATH }}/Library
key: Library-${{ matrix.targetPlatform }}-${{ hashFiles(format('{0}/Assets/**', env.UNITY_PROJECT_PATH)) }}
restore-keys: |
Library-${{ matrix.targetPlatform }}-
Library-
- name: Build Unity project
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: ${{ env.UNITY_PROJECT_PATH }}
targetPlatform: ${{ matrix.targetPlatform }}
buildName: CommanderAILab
versioning: Semantic
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.targetPlatform }}
path: build/${{ matrix.targetPlatform }}
retention-days: 14
+145
View File
@@ -0,0 +1,145 @@
# Commander-AI-Lab GUI + LLM Opponents Roadmap
## Project Summary
Build a playable Magic: The Gathering Commander interface where human players face AI opponents powered by a local LLM. The existing Python rules engine handles game legality and state management, while the LLM acts as the decision-making and narration brain for each AI opponent. A Pygame (or web-based) GUI renders the battlefield, zones, and card interactions. Each AI opponent gets a unique personality prompt to simulate different playstyles (aggro, control, combo, politics).
**Local LLM:** GPT-OSS 20B (Q4_K_M) via Ollama or LM Studio
---
## GUI Strategy: Two-Phase Frontend Plan
### Phase A (Prototype) — Pygame
Use **Pygame** for Phases 1–3 of development. Pure Python means the rules engine and AI opponent code plug in directly with zero bridging. Fast to iterate — a working battlefield renderer can be built in a day. Handles game loop, input events, and card rendering naturally. Ideal for testing AI decisions visually before investing in a polished UI.
### Phase B (Production) — Flask/FastAPI + Web Frontend
Once the core AI gameplay is working, migrate the UI to a **Flask or FastAPI backend + HTML/JS frontend** (vanilla JS or React). Benefits:
- Far more polished look for a Commander game
- Card art from Scryfall API renders natively in browser
- Easier to add multiplayer later via WebSockets
- Python backend stays pure — frontend calls it via REST API
- No Python/JS bridge complexity; clean separation of concerns
### GUI Framework Comparison
| Option | Language | Curve | Best For | Weakness |
|---|---|---|---|---|
| **Pygame** ✅ Phase A | Python | Low | Quick prototype, game loops | Dated UI, manual widgets |
| **Flask + HTML/JS** ✅ Phase B | Python + JS | Medium | Polished UI, browser-based | JS context switch |
| PyQt6 / PySide6 | Python | Medium | Desktop app widgets | Verbose, overkill for a game |
| Godot | GDScript | Medium | Full game engine, animations | Separate codebase from engine |
| Electron + Python | JS + Python | High | Desktop with web UI | Heavy, complex bridge |
| Tkinter | Python | Low | Simple UI | Very dated look |
---
## Architecture Overview
```
Game State (Python dict/dataclass)
↓
Legal Moves Generator (rules engine)
↓
LLM Prompt: "Given this state and these legal moves, what do you do?"
↓ (GPT-OSS 20B Q4_K_M)
LLM Response → parsed action
↓
Rules Engine executes + validates
↓
GUI updates board state (Pygame → Web)
```
---
## Task List
### Phase 1 — Game State Serialization
- [ ] Define a compressed game state schema (Python dataclass or dict) covering: hand, battlefield, graveyard, exile, stack, life totals, commander zone, mana pool
- [ ] Write a `state_to_prompt()` function that converts game state to a compact LLM-readable string
- [ ] Write a `legal_moves_to_prompt()` function that lists valid actions in numbered format for LLM selection
- [ ] Add a state snapshot logger for debugging AI decisions
### Phase 2 — LLM AI Opponent Integration
- [ ] Load GPT-OSS 20B (Q4_K_M) in Ollama or LM Studio; verify inference speed on RTX 5070 Ti
- [ ] Benchmark VRAM usage for GPT-OSS 20B Q4_K_M (expected ~12–14 GB VRAM at Q4_K_M quantization)
- [ ] Build `AIOpponent` class with fields: name, personality_prompt, model_endpoint, deck
- [ ] Implement `decide_action(game_state, legal_moves)` method — sends prompt to GPT-OSS 20B, parses LLM response, returns validated action index
- [ ] Add fallback logic: if LLM returns invalid/unparseable action, default to highest-priority heuristic move
- [ ] Write personality prompt templates (Aggro Timmy, Control Spike, Combo Johnny, Political Negotiator)
- [ ] Add narration method: `narrate_play(action)` — GPT-OSS 20B generates in-character flavor text for each play
- [ ] Tune system prompt length to stay within GPT-OSS 20B context window limits
### Phase 3 — Multi-Player Turn Management
- [ ] Implement turn queue: Player → AI1 → AI2 → AI3 → repeat
- [ ] Run AI decisions asynchronously (threading or asyncio) so GUI stays responsive during LLM inference
- [ ] Add priority-passing logic for instants/triggers during other players' turns
- [ ] Implement basic AI threat assessment: each AI scores opponents by board state danger level
### Phase 4 — Pygame Prototype GUI
- [ ] Design zone layout: hand, battlefield (creature/non-creature split), graveyard, exile, command zone, stack
- [ ] Build card rendering: card art placeholder, name, P/T or loyalty, tapped/untapped state
- [ ] Add click-to-play card interaction for human player
- [ ] Display AI narration/flavor text in a side panel
- [ ] Add life total trackers and commander damage matrix (4-player)
- [ ] Build phase/step indicator (Main 1, Combat, Main 2, etc.)
- [ ] Add end-of-game screen with win condition display
### Phase 5 — Web Frontend Migration *(Flask/FastAPI + HTML/JS)*
- [ ] Set up Flask or FastAPI backend exposing game state as REST API
- [ ] Build HTML/JS board layout mirroring Pygame zone design
- [ ] Integrate Scryfall API for card art rendering
- [ ] Add WebSocket support for real-time game state updates
- [ ] Add drag-and-drop card interaction
- [ ] Port AI narration panel to web chat-style UI
- [ ] Test full game loop end-to-end on web frontend
### Phase 6 — Politics & Table Talk *(Stretch Goal)*
- [ ] Implement inter-AI negotiation: before attacking, AI queries GPT-OSS 20B "should I propose a deal?"
- [ ] Build deal proposal UI: AI proposes, human can accept/decline, other AIs respond
- [ ] Add threat-based targeting memory: AIs remember who attacked them last turn
### Phase 7 — Testing & Tuning
- [ ] Run 10 full simulated games (4 AI vs AI) and log decision quality
- [ ] Benchmark GPT-OSS 20B Q4_K_M inference time per decision on RTX 5070 Ti
- [ ] Tune context window compression to stay within model token limits
- [ ] Profile Pygame and web render loops for stability during AI thinking phases
- [ ] Add unit tests for `state_to_prompt()` and `decide_action()` parsing
### Phase 8 — Multiplayer (Human vs Human + AI)
- [ ] **Lobby system** — create/join game rooms with configurable seat count (2–4 players); assign each seat as Human or AI
- [ ] **WebSocket game server** — upgrade Flask/FastAPI backend to handle persistent connections for real-time state sync across multiple browser clients
- [ ] **Player session management** — unique session tokens per human player; reconnect support if connection drops mid-game
- [ ] **Private hand views** — each human player sees only their own hand; other hands shown as face-down card backs
- [ ] **Turn notification system** — alert active player when it's their turn (browser notification or in-page indicator)
- [ ] **Action broadcast** — all players see each action played in real-time (card played, attack declared, ability activated) via WebSocket push
- [ ] **Mixed lobby support** — allow any combination of human and AI seats (e.g., 2 humans + 2 AIs, or 1 human + 3 AIs)
- [ ] **Spectator mode** — allow observers to watch a game without participating; full board visible, no hand info
- [ ] **Local LAN play** — host game server on local network so friends can connect via browser on the same Wi-Fi (no internet required)
- [ ] **Optional: Remote play** — expose server via ngrok or Tailscale for play over the internet without full deployment
- [ ] **Multiplayer AI pacing** — add configurable AI decision delay (e.g., 1–3 sec) so human players can follow AI turns without them resolving instantly
- [ ] **Chat panel** — in-game text chat between human players; AI opponents can optionally post narration lines into the same channel
- [ ] **End-of-game summary** — show full game recap (turns played, damage dealt, commanders cast count) visible to all players after win condition met
---
## Tech Stack
| Component | Choice | Notes |
|---|---|---|
| GUI (Prototype) | **Pygame** | Pure Python, fast iteration |
| GUI (Production) | **Flask/FastAPI + HTML/JS** | Polished, browser-based |
| Multiplayer | **WebSockets (Flask-SocketIO)** | Real-time state sync across clients |
| LLM Backend | Ollama or LM Studio | Serves GPT-OSS 20B locally |
| Model | **GPT-OSS 20B (Q4_K_M)** | ~12–14 GB VRAM, strong reasoning |
| Game State | Python dataclass | Structured, easy to serialize |
| Async | asyncio / threading | Keep GUI responsive during inference |
| Card Data | Scryfall API | Free MTG card data + art |
| LAN Hosting | Local network / ngrok | No deployment needed for local play |
### GPT-OSS 20B Q4_K_M Notes
- Q4_K_M quantization balances quality and speed well for a 20B model
- Expected VRAM: ~12–14 GB on RTX 5070 Ti (16 GB VRAM) — fits comfortably
- Stronger reasoning than 7B models — better for multi-step MTG decision making
- Slower inference than smaller models — async handling is essential so GUI doesn't block
- System prompt + game state should be kept under ~3000 tokens to maintain fast response times
+74
View File
@@ -0,0 +1,74 @@
# Commander AI Lab — Developer Makefile
# ======================================
# Targets:
# make test — run all tests
# make test-unit — run unit tests only (fast)
# make test-integration— run integration tests
# make test-benchmark — run benchmark suite with -s (print timings)
# make coverage — run tests with coverage report
# make serve — start the FastAPI dev server
# make lint — ruff + mypy
# make fmt — ruff format
# make clean — remove build artefacts
PYTHON ?= python
UVICORN ?= uvicorn
PORT ?= 8000
SRC = src/commander_ai_lab
.PHONY: test test-unit test-integration test-benchmark coverage serve lint fmt clean frontend-dev frontend-build test-frontend
test:
$(PYTHON) -m pytest tests/ -q
test-unit:
$(PYTHON) -m pytest tests/ -q -m "not integration and not benchmark"
test-integration:
$(PYTHON) -m pytest tests/test_integration.py -v
test-benchmark:
$(PYTHON) -m pytest tests/test_benchmark.py -v -s
test-phase3:
$(PYTHON) -m pytest tests/test_phase3.py -v
test-phase4:
$(PYTHON) -m pytest tests/test_phase4.py -v
test-phase5:
$(PYTHON) -m pytest tests/test_phase5.py -v
test-phase6:
$(PYTHON) -m pytest tests/test_phase6.py -v
test-phase7:
$(PYTHON) -m pytest tests/test_integration.py tests/test_benchmark.py -v
coverage:
$(PYTHON) -m pytest tests/ --cov=$(SRC) --cov-report=term-missing --cov-report=html
echo "Coverage report: htmlcov/index.html"
serve:
$(UVICORN) commander_ai_lab.web.app:app --reload --port $(PORT)
lint:
$(PYTHON) -m ruff check $(SRC) tests/
$(PYTHON) -m mypy $(SRC) --ignore-missing-imports
fmt:
$(PYTHON) -m ruff format $(SRC) tests/
clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name '*.pyc' -delete
rm -rf .coverage htmlcov .mypy_cache .ruff_cache dist build
frontend-dev:
cd frontend && npm run dev
frontend-build:
cd frontend && npm run build
test-frontend:
cd frontend && npx vitest run
+19
View File
@@ -354,6 +354,25 @@ Each batch run produces JSON conforming to `docs/batch-result-schema.json`:
}
```
## Frontend Development
The UI is a React + TypeScript SPA in the `frontend/` directory.
### Dev server (with hot reload)
```bash
cd frontend
npm install
npm run dev
```
Vite proxies `/api` and `/ws` to `localhost:8000`, so start the FastAPI backend first.
### Production build
```bash
cd frontend
npm run build
```
FastAPI serves the built assets from `frontend/dist/`.
## Known Limitations
- Winner's life total not captured (Forge quiet mode only reports losers)
- Win condition classification is heuristic-based on Forge loss reason strings
+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
+5 -1
View File
@@ -7,6 +7,10 @@ Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will call the Archidekt API.
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.archidekt")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -26,5 +30,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
# 1. Search Archidekt for popular decks with this commander
# 2. Fetch the top 3-5 decklists
# 3. Normalize card names and return as TemplateDeck
print(f" [ARCHIDEKT] Stub: No template decks for '{commander_name}' (not yet implemented)")
log.info(f" Stub: No template decks for '{commander_name}' (not yet implemented)")
return []
+5 -1
View File
@@ -7,6 +7,10 @@ Currently returns a hardcoded sample for end-to-end wiring.
Real implementation will parse EDHREC commander pages.
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.edhrec")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -27,5 +31,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
# 2. Fetch the "Average Deck" or "Top Cards" list
# 3. Parse card names and inclusion percentages
# 4. Return as a single TemplateDeck with weighted cards
print(f" [EDHREC] Stub: No recommendations for '{commander_name}' (not yet implemented)")
log.info(f" Stub: No recommendations for '{commander_name}' (not yet implemented)")
return []
+125 -9
View File
@@ -1,17 +1,133 @@
"""
Moxfield Adapter (stub)
════════════════════════
Moxfield Adapter
================
Fetches template decklists from Moxfield for a given commander.
Currently returns empty (experimental — not yet implemented).
Uses the undocumented but usable api2.moxfield.com API.
Rate-limited to <=10 req/sec. Errors are caught and logged.
"""
import logging
import time
import httpx
from .models import TemplateDeck, TemplateDeckCard
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
MOXFIELD_API = "https://api2.moxfield.com"
HEADERS = {
"User-Agent": "CommanderAILab/1.0",
"Accept": "application/json",
}
# Minimum seconds between requests (<=10 req/sec)
_RATE_LIMIT_DELAY = 0.12
def _slugify_commander(name: str) -> str:
"""Return the commander name as-is for the Moxfield search query."""
return name.strip()
def _build_cards_from_deck(deck_data: dict) -> list:
"""Extract commander + mainboard cards from a full Moxfield deck response."""
cards = []
# Commanders first
for card_name, card_obj in deck_data.get("commanders", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
# Mainboard
for card_name, card_obj in deck_data.get("mainboard", {}).items():
qty = card_obj.get("quantity", 1)
cards.append(TemplateDeckCard(name=card_name, quantity=qty))
return cards
def fetch_template_decks(
commander_name: str,
color_identity: list,
config: dict = None,
) -> list:
"""
Fetch template decks from Moxfield for the given commander.
Experimental — not yet implemented.
Fetch top Commander decks from Moxfield for the given commander.
Returns a list of TemplateDeck objects. Returns empty list on any
API failure — errors are logged, never raised.
Args:
commander_name: The commander's card name.
color_identity: List of color symbols (unused, kept for interface compat).
config: Optional dict. Supported key: ``max_decks`` (default 3).
Returns:
List of TemplateDeck objects populated with commander + mainboard cards.
"""
print(f" [MOXFIELD] Stub: Not yet implemented for '{commander_name}'")
return []
max_decks = (config or {}).get("max_decks", 3)
results = []
try:
# Step 1 — Search for top decks by commander name
search_url = f"{MOXFIELD_API}/v2/decks/search"
params = {
"q": "",
"fmt": "commander",
"commander": _slugify_commander(commander_name),
"sortType": "views",
"sortDirection": "Descending",
"pageSize": max_decks,
}
log.info(f" Moxfield: Searching for '{commander_name}' (max={max_decks})")
resp = httpx.get(search_url, params=params, headers=HEADERS, timeout=15)
resp.raise_for_status()
search_data = resp.json()
deck_summaries = search_data.get("data", [])[:max_decks]
if not deck_summaries:
log.info(f" Moxfield: No decks found for '{commander_name}'")
return []
log.info(f" Moxfield: Found {len(deck_summaries)} deck(s) for '{commander_name}', fetching details...")
# Step 2 — Fetch full detail for each deck
for summary in deck_summaries:
deck_id = summary.get("publicId")
deck_name = summary.get("name", f"Moxfield {commander_name}")
if not deck_id:
log.warning(" Moxfield: Skipping deck with no publicId")
continue
time.sleep(_RATE_LIMIT_DELAY) # Rate limit: <=10 req/sec
try:
deck_url = f"{MOXFIELD_API}/v3/decks/all/{deck_id}"
deck_resp = httpx.get(deck_url, headers=HEADERS, timeout=15)
deck_resp.raise_for_status()
deck_data = deck_resp.json()
except Exception as deck_err:
log.warning(f" Moxfield: Failed to fetch deck '{deck_id}': {deck_err}")
continue
cards = _build_cards_from_deck(deck_data)
if not cards:
log.warning(f" Moxfield: Deck '{deck_name}' ({deck_id}) had no cards, skipping")
continue
results.append(TemplateDeck(
name=f"{deck_name} (Moxfield)",
source=f"https://moxfield.com/decks/{deck_id}",
cards=cards,
))
log.info(f" Moxfield: Added deck '{deck_name}' ({len(cards)} cards)")
log.info(f" Moxfield: Returning {len(results)} deck(s) for '{commander_name}'")
except httpx.HTTPStatusError as e:
log.warning(
f" Moxfield fetch failed for '{commander_name}': "
f"HTTP {e.response.status_code} — {e}"
)
except httpx.RequestError as e:
log.warning(f" Moxfield request error for '{commander_name}': {e}")
except Exception as e:
log.warning(f" Moxfield unexpected error for '{commander_name}': {e}")
return results
+5 -1
View File
@@ -6,6 +6,10 @@ Fetches template decklists from MTGGoldfish for a given commander.
Currently returns empty (experimental — not yet implemented).
"""
from .models import TemplateDeck, TemplateDeckCard
import logging
log = logging.getLogger("commander_ai_lab.deckgen.mtggoldfish")
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
@@ -13,5 +17,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
Fetch template decks from MTGGoldfish for the given commander.
Experimental — not yet implemented.
"""
print(f" [MTGGOLDFISH] Stub: Not yet implemented for '{commander_name}'")
log.info(f" Stub: Not yet implemented for '{commander_name}'")
return []
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -133,7 +133,7 @@ export function SimulatorPage() {
/>
<input
type="text"
placeholder="Base URL (e.g. http://192.168.0.122:1234)"
placeholder="Base URL (e.g. http://192.168.0.240:11434)"
value={dsUrl}
onChange={e => setDsUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
@@ -559,5 +559,4 @@ export interface CommanderSearchResult {
export interface PplxStatus {
configured: boolean
key_prefix: string
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend-tmp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+5077
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "commander-ai-lab-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.0.0",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

After

Width:  |  Height:  |  Size: 44 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

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

After

Width:  |  Height:  |  Size: 8.5 KiB

+54
View File
@@ -0,0 +1,54 @@
import type { GameState } from '../types/game';
import { SEAT_COLOURS } from '../constants';
import PlayerZone from './PlayerZone';
import CardChip from './CardChip';
interface BoardProps {
gameState: GameState;
thinkingSeats: Set<number>;
}
export default function Board({ gameState, thinkingSeats }: BoardProps) {
const { players, active_seat, stack } = gameState;
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
const renderZone = (seat: number, className: string) => {
const player = getPlayer(seat);
if (!player) return <div className={className} />;
return (
<div className={className}>
<PlayerZone
player={player}
isActive={active_seat === seat}
isThinking={thinkingSeats.has(seat)}
seatColour={SEAT_COLOURS[seat]}
/>
</div>
);
};
return (
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
{renderZone(1, 'row-start-1 col-start-1')}
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
<div className="flex flex-wrap gap-0.5">
{stack.map((card, i) => (
<CardChip key={i} card={card} />
))}
</div>
</div>
{renderZone(2, 'row-start-1 col-start-3')}
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
<div className="row-start-2 col-start-1" />
<div className="row-start-2 col-start-3" />
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
{renderZone(0, 'row-start-3 col-start-1')}
{renderZone(3, 'row-start-3 col-start-3')}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Card } from '../types/game';
interface CardChipProps {
card: Card;
onClick?: () => void;
}
export default function CardChip({ card, onClick }: CardChipProps) {
return (
<span
onClick={onClick}
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
card.tapped ? 'opacity-55 italic' : ''
}`}
>
{card.name}
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
</span>
);
}
@@ -0,0 +1,40 @@
import type { Player } from '../types/game';
import { SEAT_COLOURS } from '../constants';
interface GameOverOverlayProps {
show: boolean;
winner: Player | null;
reason: string;
players: Player[];
}
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
if (!show) return null;
return (
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
{winner && (
<h2
className="text-4xl font-bold"
style={{ color: SEAT_COLOURS[winner.seat] }}
>
{winner.name} Wins!
</h2>
)}
<p className="text-base text-[#aaa] mt-2">{reason}</p>
<ul className="mt-6 space-y-1">
{players.map((p) => (
<li key={p.seat} className="text-sm">
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
{p.name} — {p.life} life
{p.eliminated && (
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
)}
</li>
))}
</ul>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { Card } from '../types/game';
interface HandCardProps {
card: Card;
index: number;
isSelected: boolean;
onClick: () => void;
}
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
return (
<button
onClick={onClick}
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
isSelected
? 'border border-[#ffdc32] bg-card-selected'
: 'bg-card-hand border border-[#5060a0]'
}`}
>
{card.name}
</button>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react';
import type { Card } from '../types/game';
import HandCard from './HandCard';
interface MoveBarProps {
hand: Card[];
humanSeat: number;
onPass: () => void;
onConcede: () => void;
onPlayCard: (index: number) => void;
status: string;
}
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const handleCardClick = (index: number) => {
if (selectedIndex === index) {
onPlayCard(index);
setSelectedIndex(null);
} else {
setSelectedIndex(index);
}
};
return (
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
<button
onClick={onPass}
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
>
Pass Priority
</button>
<button
onClick={() => {
if (window.confirm('Are you sure you want to concede?')) onConcede();
}}
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
>
Concede
</button>
{hand.map((card, i) => (
<HandCard
key={i}
card={card}
index={i}
isSelected={selectedIndex === i}
onClick={() => handleCardClick(i)}
/>
))}
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
</div>
);
}
@@ -0,0 +1,68 @@
import { useRef, useEffect, useState } from 'react';
import type { NarrationLine } from '../types/game';
import { SEAT_COLOURS } from '../constants';
interface NarrationPanelProps {
lines: NarrationLine[];
thinkingSeats: Set<number>;
}
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dots, setDots] = useState('.');
// Auto-scroll on new lines
useEffect(() => {
const el = containerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length]);
// Cycling dots for thinking animation
useEffect(() => {
if (thinkingSeats.size === 0) return;
const interval = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
}, 500);
return () => clearInterval(interval);
}, [thinkingSeats.size]);
const displayed = lines.slice(-120);
return (
<div
ref={containerRef}
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
>
{displayed.map((line, i) => {
let colorClass = 'text-[#e8e8e8]';
let style: React.CSSProperties | undefined;
if (line.isSystem) {
colorClass = 'text-[#556]';
} else if (line.seat !== undefined) {
colorClass = '';
style = { color: SEAT_COLOURS[line.seat] };
}
return (
<div
key={i}
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
style={style}
>
{line.text}
</div>
);
})}
{thinkingSeats.size > 0 &&
Array.from(thinkingSeats).map((seat) => (
<div
key={`thinking-${seat}`}
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
>
P{seat} thinking{dots}
</div>
))}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Phase, Player } from '../types/game';
import { PHASES, SEAT_COLOURS } from '../constants';
interface PhaseBarProps {
phase: Phase;
activeSeat: number;
players: Player[];
}
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
const activePlayer = players.find((p) => p.seat === activeSeat);
return (
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
{PHASES.map((p) => (
<button
key={p}
className={`text-[11px] rounded px-2 py-0.5 ${
p === phase
? 'bg-accent-blue text-white'
: 'bg-[#252a42] text-[#888]'
}`}
>
{p}
</button>
))}
{activePlayer && (
<span
className="text-[11px] ml-auto font-bold"
style={{ color: SEAT_COLOURS[activeSeat] }}
>
P{activeSeat}: {activePlayer.name}
</span>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Player } from '../types/game';
import CardChip from './CardChip';
interface PlayerZoneProps {
player: Player;
isActive: boolean;
isThinking: boolean;
seatColour: string;
}
function lifeColor(life: number): string {
if (life >= 30) return 'text-life-high';
if (life >= 15) return 'text-life-med';
return 'text-life-low';
}
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
return (
<div
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
isThinking ? 'animate-pulse' : ''
} ${player.eliminated ? 'opacity-40' : ''}`}
style={isActive ? { borderColor: seatColour } : undefined}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-[#666]">
P{player.seat}: {player.name}
</span>
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
{player.life}
</span>
</div>
<div className="flex flex-wrap gap-0.5">
{player.battlefield.map((card, i) => (
<CardChip key={i} card={card} />
))}
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import type { Phase } from './types/game';
export const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'] as const;
export const PHASES: Phase[] = [
'untap', 'upkeep', 'draw', 'main1',
'begin_combat', 'declare_attackers', 'declare_blockers', 'combat_damage',
'main2', 'end_step', 'cleanup',
];
+132
View File
@@ -0,0 +1,132 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
import { gameReducer, initialState } from '../store/gameReducer';
import type { WsIncoming, NarrationLine } from '../types/game';
export function useGameSocket(gameId: string) {
const [state, dispatch] = useReducer(gameReducer, initialState);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => {
const url = `ws://${window.location.host}/ws/game/${gameId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
dispatch({ type: 'SET_STATUS', status: 'Connected' });
};
ws.onmessage = (event) => {
const msg: WsIncoming = JSON.parse(event.data);
switch (msg.type) {
case 'state':
dispatch({ type: 'SET_STATE', payload: msg.payload });
break;
case 'hand':
dispatch({ type: 'SET_HAND', cards: msg.cards });
break;
case 'event': {
const line: NarrationLine = {
text: msg.text,
seat: msg.seat,
isSystem: false,
timestamp: Date.now(),
};
dispatch({ type: 'ADD_NARRATION', line });
break;
}
case 'phase': {
const line: NarrationLine = {
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
isSystem: true,
timestamp: Date.now(),
};
dispatch({ type: 'ADD_NARRATION', line });
break;
}
case 'thinking':
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
break;
case 'game_over':
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
break;
case 'pong':
break;
case 'error':
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
break;
}
};
ws.onclose = () => {
if (!intentionalClose.current) {
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
reconnectTimeout.current = setTimeout(() => {
connect();
}, 3000);
}
};
ws.onerror = () => {
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
};
}, [gameId]);
// Connect on mount
useEffect(() => {
intentionalClose.current = false;
connect();
return () => {
intentionalClose.current = true;
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
}, [connect]);
// Heartbeat ping every 15s
useEffect(() => {
heartbeatInterval.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 15000);
return () => {
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
};
}, []);
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
}
}, []);
const sendConcede = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'concede' }));
}
}, []);
return {
gameState: state.gameState,
narration: state.narration,
thinking: state.thinkingSeats,
hand: state.hand,
gameOver: state.gameOver,
status: state.status,
sendMove,
sendConcede,
};
}
+163
View File
@@ -0,0 +1,163 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
export interface Seat {
seat: number;
name: string;
ready: boolean;
isAi: boolean;
}
export interface RoomState {
roomId: string;
roomName: string;
host: string;
seats: (Seat | null)[];
}
export interface ChatMessage {
role: 'PLAYER' | 'AI' | 'SYSTEM';
name: string;
text: string;
timestamp: number;
}
type LobbyAction =
| { type: 'SET_ROOM'; payload: RoomState }
| { type: 'ADD_CHAT'; message: ChatMessage }
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
| { type: 'LAUNCH'; gameId: string };
interface LobbyState {
roomState: RoomState | null;
chat: ChatMessage[];
launchedGameId: string | null;
}
const initialState: LobbyState = {
roomState: null,
chat: [],
launchedGameId: null,
};
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
switch (action.type) {
case 'SET_ROOM':
return { ...state, roomState: action.payload };
case 'ADD_CHAT':
return { ...state, chat: [...state.chat, action.message].slice(-50) };
case 'SET_CHAT_HISTORY':
return { ...state, chat: action.messages.slice(-50) };
case 'LAUNCH':
return { ...state, launchedGameId: action.gameId };
default:
return state;
}
}
export function useLobbySock(roomId: string, playerName: string) {
const [state, dispatch] = useReducer(lobbyReducer, initialState);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => {
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'lobby_state':
dispatch({ type: 'SET_ROOM', payload: msg.payload });
break;
case 'chat':
dispatch({
type: 'ADD_CHAT',
message: {
role: msg.role ?? 'PLAYER',
name: msg.name ?? '',
text: msg.text,
timestamp: Date.now(),
},
});
break;
case 'chat_history':
dispatch({
type: 'SET_CHAT_HISTORY',
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
role: m.role ?? 'SYSTEM',
name: m.name ?? '',
text: m.text,
timestamp: Date.now(),
})),
});
break;
case 'launch':
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
break;
case 'pong':
break;
}
};
ws.onclose = () => {
if (!intentionalClose.current) {
reconnectTimeout.current = setTimeout(() => connect(), 3000);
}
};
}, [roomId, playerName]);
useEffect(() => {
intentionalClose.current = false;
connect();
return () => {
intentionalClose.current = true;
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
}, [connect]);
// Heartbeat ping every 15s
useEffect(() => {
heartbeatInterval.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 15000);
return () => {
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
};
}, []);
const sendReady = useCallback((seat: number) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
}
}, []);
const sendChat = useCallback((text: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
}
}, []);
const sendLaunch = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'launch' }));
}
}, []);
return {
roomState: state.roomState,
chat: state.chat,
launchedGameId: state.launchedGameId,
sendReady,
sendChat,
sendLaunch,
};
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+53
View File
@@ -0,0 +1,53 @@
import { useParams } from 'react-router-dom';
import { useGameSocket } from '../hooks/useGameSocket';
import PhaseBar from '../components/PhaseBar';
import Board from '../components/Board';
import NarrationPanel from '../components/NarrationPanel';
import MoveBar from '../components/MoveBar';
import GameOverOverlay from '../components/GameOverOverlay';
export default function GamePage() {
const { gameId } = useParams<{ gameId: string }>();
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
useGameSocket(gameId!);
if (!gameState) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
Connecting…
</div>
);
}
const winnerPlayer = gameOver
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
: null;
return (
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
<PhaseBar
phase={gameState.current_phase}
activeSeat={gameState.active_seat}
players={gameState.players}
/>
<div className="flex flex-1 overflow-hidden">
<Board gameState={gameState} thinkingSeats={thinking} />
<NarrationPanel lines={narration} thinkingSeats={thinking} />
</div>
<MoveBar
hand={hand}
humanSeat={0}
onPass={() => sendMove('pass')}
onConcede={sendConcede}
onPlayCard={(index) => sendMove('play_card', { index })}
status={status}
/>
<GameOverOverlay
show={gameOver !== null}
winner={winnerPlayer}
reason={gameOver?.reason ?? ''}
players={gameState.players}
/>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
interface RoomSummary {
roomId: string;
name: string;
host: string;
playerCount: number;
}
export default function LobbyPage() {
const navigate = useNavigate();
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(true);
// Create room form state
const [roomName, setRoomName] = useState('');
const [hostName, setHostName] = useState('');
const [aiSlots, setAiSlots] = useState(0);
const [password, setPassword] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
fetch('/api/v1/lobby/rooms')
.then((res) => res.json())
.then((data) => setRooms(data))
.catch(() => setRooms([]))
.finally(() => setLoading(false));
}, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
try {
const res = await fetch('/api/v1/lobby/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roomName,
host: hostName,
ai_slots: aiSlots,
password: password || undefined,
}),
});
const data = await res.json();
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
} catch {
setCreating(false);
}
};
return (
<div className="min-h-screen bg-bg-base text-white p-6">
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
{/* Room List */}
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
{loading ? (
<p className="text-text-muted text-sm">Loading...</p>
) : rooms.length === 0 ? (
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
) : (
<ul className="space-y-2">
{rooms.map((room) => (
<li key={room.roomId}>
<button
onClick={() => navigate(`/room/${room.roomId}`)}
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
>
<span className="font-medium">{room.name}</span>
<span className="text-text-muted text-sm ml-2">
Host: {room.host} · {room.playerCount}/4 players
</span>
</button>
</li>
))}
</ul>
)}
</div>
{/* Create Room Form */}
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="block text-sm text-text-muted mb-1">Room Name</label>
<input
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
required
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Your Name</label>
<input
type="text"
value={hostName}
onChange={(e) => setHostName(e.target.value)}
required
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">AI Players</label>
<select
value={aiSlots}
onChange={(e) => setAiSlots(Number(e.target.value))}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value={0}>0 AI players</option>
<option value={1}>1 AI player</option>
<option value={2}>2 AI players</option>
<option value={3}>3 AI players</option>
</select>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<button
type="submit"
disabled={creating}
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Room'}
</button>
</form>
</div>
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { useLobbySock } from '../hooks/useLobbySock';
import type { ChatMessage } from '../hooks/useLobbySock';
const ROLE_COLORS: Record<string, string> = {
PLAYER: 'text-accent-blue',
AI: 'text-accent-purple',
SYSTEM: 'text-text-muted',
};
function ChatBadge({ role }: { role: string }) {
return (
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
[{role}]
</span>
);
}
export default function RoomPage() {
const { roomId } = useParams<{ roomId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isSpectator = searchParams.get('spectate') === '1';
const nameFromUrl = searchParams.get('name') ?? '';
const [playerName, setPlayerName] = useState(nameFromUrl);
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
const [chatInput, setChatInput] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
useLobbySock(roomId!, nameConfirmed ? playerName : '');
// Redirect when game launches
useEffect(() => {
if (launchedGameId) {
navigate(`/game/${launchedGameId}`);
}
}, [launchedGameId, navigate]);
// Auto-scroll chat
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chat.length]);
// Determine own seat
const mySeat = roomState?.seats.findIndex(
(s) => s && !s.isAi && s.name === playerName,
) ?? -1;
const isHost = mySeat === 0;
if (!nameConfirmed) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
}}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
autoFocus
/>
<button
onClick={() => playerName.trim() && setNameConfirmed(true)}
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
>
Join Room
</button>
</div>
</div>
);
}
if (!roomState) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
Connecting to room...
</div>
);
}
const handleSendChat = () => {
if (chatInput.trim()) {
sendChat(chatInput.trim());
setChatInput('');
}
};
return (
<div className="flex flex-col h-screen bg-bg-base text-white">
{/* Header */}
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
</div>
{isSpectator && (
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
SPECTATING
</span>
)}
</div>
<div className="flex flex-1 overflow-hidden">
{/* Seat Grid */}
<div className="flex-1 p-4">
<div className="grid grid-cols-2 gap-3 max-w-lg">
{[0, 1, 2, 3].map((seatIdx) => {
const seat = roomState.seats[seatIdx];
return (
<div
key={seatIdx}
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
>
{seat ? (
<>
<span className="text-sm font-medium">
{seat.isAi ? '🤖 AI' : seat.name}
</span>
<span className="text-xs text-text-muted mt-1">
Seat {seatIdx}
</span>
{!seat.isAi && (
<span
className={`text-xs mt-1 font-bold ${
seat.ready ? 'text-life-high' : 'text-life-med'
}`}
>
{seat.ready ? 'READY' : 'Not Ready'}
</span>
)}
</>
) : (
<>
<span className="text-text-muted text-sm">Waiting...</span>
<span className="text-xs text-text-muted mt-1">
Seat {seatIdx}
</span>
</>
)}
</div>
);
})}
</div>
{/* Action buttons */}
{!isSpectator && (
<div className="mt-4 flex gap-3">
{mySeat >= 0 && (
<button
onClick={() => sendReady(mySeat)}
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
>
Toggle Ready
</button>
)}
{isHost && (
<button
onClick={async () => {
try {
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
method: 'POST',
});
const data = await res.json();
if (data.gameId) {
navigate(`/game/${data.gameId}`);
}
} catch {
// launch via WS fallback
sendLaunch();
}
}}
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
>
Launch Game
</button>
)}
</div>
)}
</div>
{/* Chat Panel */}
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
Chat
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{chat.map((msg: ChatMessage, i: number) => (
<div key={i} className="text-[11px] leading-[1.4] break-words">
<ChatBadge role={msg.role} />
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
<span className="text-text-body">{msg.text}</span>
</div>
))}
<div ref={chatEndRef} />
</div>
{!isSpectator && (
<div className="border-t border-border-sep p-2">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSendChat();
}}
placeholder="Type a message..."
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
/>
</div>
)}
</div>
</div>
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
import { useEffect, useRef, useState } from 'react';
import { SEAT_COLOURS } from '../constants';
import type { SimDeck, SimResult } from '../types/simulator';
const SEAT_LABELS = ['Seat 1 (You)', 'Seat 2', 'Seat 3', 'Seat 4'];
type SimStatus = 'idle' | 'running' | 'complete' | 'error';
export default function SimulatorPage() {
// Deck list from API
const [decks, setDecks] = useState<SimDeck[]>([]);
const [loadingDecks, setLoadingDecks] = useState(true);
// Slot selections (4 slots, null = empty)
const [slots, setSlots] = useState<(number | null)[]>([null, null, null, null]);
// Config
const [numGames, setNumGames] = useState(10);
const [engineType, setEngineType] = useState('heuristic');
const [recordLogs, setRecordLogs] = useState(true);
// Sim state
const [simId, setSimId] = useState<string | null>(null);
const [status, setStatus] = useState<SimStatus>('idle');
const [completed, setCompleted] = useState(0);
const [total, setTotal] = useState(0);
const [result, setResult] = useState<SimResult | null>(null);
const [games, setGames] = useState<unknown[]>([]);
const [error, setError] = useState<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Fetch decks on mount
useEffect(() => {
fetch('/api/decks')
.then((res) => res.json())
.then((data) => {
const list = (data.decks || []).map((d: Record<string, unknown>) => ({
id: d.id as number,
name: d.name as string,
commander_name: (d.commander_name || '') as string,
color_identity: (d.color_identity || []) as string[],
}));
setDecks(list);
})
.catch(() => setDecks([]))
.finally(() => setLoadingDecks(false));
}, []);
// Polling
useEffect(() => {
if (status !== 'running' || !simId) return;
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/sim/status?simId=${simId}`);
const data = await res.json();
setCompleted(data.completed ?? 0);
setTotal(data.total ?? 0);
if (data.status === 'complete') {
setStatus('complete');
// Fetch results
const rres = await fetch(`/api/sim/result?simId=${simId}`);
const rdata = await rres.json();
setResult(rdata.summary ?? null);
setGames(rdata.games ?? []);
} else if (data.status === 'error') {
setStatus('error');
setError(data.error || 'Simulation failed');
}
} catch {
// keep polling
}
}, 1000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [status, simId]);
const selectedCount = slots.filter((s) => s !== null).length;
const canRun = selectedCount >= 2 && status !== 'running';
const handleRun = async () => {
const deckIds = slots.filter((s): s is number => s !== null);
setStatus('running');
setCompleted(0);
setTotal(numGames);
setResult(null);
setGames([]);
setError(null);
try {
const res = await fetch('/api/sim/run-n-player', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deckIds,
numGames,
recordLogs,
engineType,
}),
});
const data = await res.json();
if (!res.ok) {
setStatus('error');
setError(data.error || 'Failed to start simulation');
return;
}
setSimId(data.simId);
setTotal(data.total);
} catch {
setStatus('error');
setError('Network error');
}
};
const setSlot = (idx: number, value: number | null) => {
setSlots((prev) => {
const next = [...prev];
next[idx] = value;
return next;
});
};
const getDeckById = (id: number) => decks.find((d) => d.id === id);
return (
<div className="min-h-screen bg-bg-base text-white p-6">
<div className="max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Batch Simulator</h1>
<p className="text-text-muted text-sm mb-6">
Run N-player Monte Carlo simulations with your decks.
</p>
{/* Deck Selection */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Deck Slots</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{SEAT_LABELS.map((label, idx) => {
const selected = slots[idx];
const deck = selected !== null ? getDeckById(selected) : null;
return (
<div
key={idx}
className="bg-bg-zone rounded-md p-4"
style={{
border: `2px solid ${selected !== null ? SEAT_COLOURS[idx] : '#3a4060'}`,
boxShadow: selected !== null ? `0 0 8px ${SEAT_COLOURS[idx]}40` : 'none',
}}
>
<div
className="text-sm font-medium mb-2"
style={{ color: SEAT_COLOURS[idx] }}
>
{label}
</div>
<select
value={selected ?? ''}
onChange={(e) => setSlot(idx, e.target.value ? Number(e.target.value) : null)}
className="w-full bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="">— Empty —</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
{deck && (
<div className="mt-2 text-xs text-text-muted">
{deck.commander_name && (
<div className="truncate">{deck.commander_name}</div>
)}
{deck.color_identity.length > 0 && (
<div>{deck.color_identity.join(' ')}</div>
)}
</div>
)}
</div>
);
})}
</div>
{loadingDecks && (
<p className="text-text-muted text-sm mt-2">Loading decks...</p>
)}
</div>
{/* Config Row */}
<div className="flex flex-wrap items-end gap-4 mb-6 bg-bg-zone rounded-md border border-border-zone p-4">
<div>
<label className="block text-sm text-text-muted mb-1">Games</label>
<input
type="number"
min={1}
max={1000}
value={numGames}
onChange={(e) => setNumGames(Math.max(1, Math.min(1000, Number(e.target.value))))}
className="w-24 bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Engine</label>
<select
value={engineType}
onChange={(e) => setEngineType(e.target.value)}
className="bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="heuristic">Heuristic AI</option>
<option value="deepseek">DeepSeek AI</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="recordLogs"
checked={recordLogs}
onChange={(e) => setRecordLogs(e.target.checked)}
className="rounded"
/>
<label htmlFor="recordLogs" className="text-sm text-text-muted">
Record Logs
</label>
</div>
<button
onClick={handleRun}
disabled={!canRun}
className="bg-accent-blue text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed"
>
{status === 'running' ? (
<span className="flex items-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Running...
</span>
) : (
'Run Simulation'
)}
</button>
</div>
{/* Progress */}
{status === 'running' && (
<div className="mb-6">
<div className="flex justify-between text-sm text-text-muted mb-1">
<span>Progress</span>
<span>
{completed} / {total} games
</span>
</div>
<div className="w-full bg-bg-panel rounded-full h-3 overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all duration-300"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
</div>
)}
{/* Error */}
{status === 'error' && error && (
<div className="mb-6 bg-red-900/30 border border-red-700 rounded-md p-3 text-sm text-red-300">
{error}
</div>
)}
{/* Results */}
{status === 'complete' && result && (
<div className="space-y-6">
<div className="flex items-center gap-4 text-sm text-text-muted">
<span>{result.totalGames} games</span>
<span>{result.playerCount} players</span>
<span>{result.elapsedSeconds}s elapsed</span>
</div>
{/* Win Rate Bar Chart */}
<div className="bg-bg-zone rounded-md border border-border-zone p-4">
<h3 className="text-sm font-semibold mb-3">Win Rates</h3>
<div className="space-y-2">
{result.players.map((p) => (
<div key={p.seat} className="flex items-center gap-3">
<div className="w-28 text-sm truncate" style={{ color: SEAT_COLOURS[p.seat] }}>
{p.deckName}
</div>
<div className="flex-1 bg-bg-panel rounded-full h-5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${p.winRate}%`,
backgroundColor: SEAT_COLOURS[p.seat],
minWidth: p.winRate > 0 ? '2px' : '0',
}}
/>
</div>
<div className="w-16 text-right text-sm font-mono">
{p.winRate}%
</div>
</div>
))}
</div>
</div>
{/* Stats Table */}
<div className="bg-bg-zone rounded-md border border-border-zone overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-zone text-text-muted">
<th className="text-left px-3 py-2">Seat</th>
<th className="text-left px-3 py-2">Deck</th>
<th className="text-right px-3 py-2">Wins</th>
<th className="text-right px-3 py-2">Win%</th>
<th className="text-right px-3 py-2">Avg Turns</th>
<th className="text-right px-3 py-2">Avg Dmg</th>
<th className="text-right px-3 py-2">Avg Spells</th>
<th className="text-right px-3 py-2">Avg Creatures</th>
<th className="text-right px-3 py-2">Avg Removal</th>
</tr>
</thead>
<tbody>
{result.players.map((p) => {
const isWinner = result.players.every((o) => o.wins <= p.wins);
return (
<tr
key={p.seat}
className={`border-b border-border-zone ${isWinner ? 'font-semibold' : ''}`}
style={{
backgroundColor: `${SEAT_COLOURS[p.seat]}${isWinner ? '20' : '0a'}`,
}}
>
<td className="px-3 py-2" style={{ color: SEAT_COLOURS[p.seat] }}>
P{p.seat + 1}
</td>
<td className="px-3 py-2">{p.deckName}</td>
<td className="text-right px-3 py-2">{p.wins}</td>
<td className="text-right px-3 py-2">{p.winRate}%</td>
<td className="text-right px-3 py-2">{p.avgTurns}</td>
<td className="text-right px-3 py-2">{p.avgDamageDealt}</td>
<td className="text-right px-3 py-2">{p.avgSpellsCast}</td>
<td className="text-right px-3 py-2">{p.avgCreaturesPlayed}</td>
<td className="text-right px-3 py-2">{p.avgRemovalUsed}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Per-Game Logs (collapsible) */}
{games.length > 0 && (
<div className="bg-bg-zone rounded-md border border-border-zone">
<button
onClick={() => setShowLogs(!showLogs)}
className="w-full text-left px-4 py-3 text-sm font-medium flex justify-between items-center hover:bg-bg-panel transition-colors"
>
<span>Per-Game Results ({games.length} games)</span>
<span className="text-text-muted">{showLogs ? '▲' : '▼'}</span>
</button>
{showLogs && (
<div className="px-4 pb-3 max-h-96 overflow-y-auto">
<div className="space-y-1">
{games.map((g: unknown, i: number) => {
const game = g as Record<string, unknown>;
return (
<div
key={i}
className="flex items-center gap-3 text-xs text-text-muted py-1 border-b border-border-zone"
>
<span className="w-12 font-mono">#{game.gameNumber as number}</span>
<span>
Winner:{' '}
<span
style={{
color: SEAT_COLOURS[(game.winner ?? game.winningSeat ?? 0) as number],
}}
>
P{((game.winner ?? game.winningSeat ?? 0) as number) + 1}
</span>
</span>
<span>{game.turns as number} turns</span>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { GameState, Card, NarrationLine } from '../types/game';
export type GameAction =
| { type: 'SET_STATE'; payload: GameState }
| { type: 'SET_HAND'; cards: Card[] }
| { type: 'ADD_NARRATION'; line: NarrationLine }
| { type: 'SET_THINKING'; seat: number; active: boolean }
| { type: 'GAME_OVER'; winner: number; reason: string }
| { type: 'SET_STATUS'; status: string };
export interface ReducerState {
gameState: GameState | null;
hand: Card[];
narration: NarrationLine[];
thinkingSeats: Set<number>;
gameOver: { winner: number; reason: string } | null;
status: string;
}
export const initialState: ReducerState = {
gameState: null,
hand: [],
narration: [],
thinkingSeats: new Set(),
gameOver: null,
status: '',
};
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
switch (action.type) {
case 'SET_STATE':
return { ...state, gameState: action.payload };
case 'SET_HAND':
return { ...state, hand: action.cards };
case 'ADD_NARRATION': {
const narration = [...state.narration, action.line];
return { ...state, narration: narration.slice(-120) };
}
case 'SET_THINKING': {
const thinkingSeats = new Set(state.thinkingSeats);
if (action.active) {
thinkingSeats.add(action.seat);
} else {
thinkingSeats.delete(action.seat);
}
return { ...state, thinkingSeats };
}
case 'GAME_OVER':
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
case 'SET_STATUS':
return { ...state, status: action.status };
default:
return state;
}
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+55
View File
@@ -0,0 +1,55 @@
export type Phase =
| 'untap' | 'upkeep' | 'draw' | 'main1'
| 'begin_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage'
| 'main2' | 'end_step' | 'cleanup';
export interface Card {
name: string;
type: string;
tapped: boolean;
is_commander: boolean;
oracle: string;
cmc: number;
}
export interface Player {
seat: number;
name: string;
life: number;
eliminated: boolean;
hand: Card[];
battlefield: Card[];
graveyard: Card[];
exile: Card[];
command_zone: Card[];
hand_count: number;
}
export interface GameState {
game_id: string;
turn: number;
current_phase: Phase;
active_seat: number;
players: Player[];
stack: Card[];
game_over: boolean;
winner: Player | null;
}
export type WsIncoming =
| { type: 'state'; payload: GameState }
| { type: 'event'; text: string; seat?: number }
| { type: 'phase'; phase: Phase; active_seat: number }
| { type: 'thinking'; seat: number; active: boolean }
| { type: 'elimination'; seat: number }
| { type: 'game_over'; winner: number; reason: string }
| { type: 'hand'; cards: Card[] }
| { type: 'pong' }
| { type: 'error'; message: string };
export interface NarrationLine {
text: string;
seat?: number;
isSystem: boolean;
timestamp: number;
}
+28
View File
@@ -0,0 +1,28 @@
export interface SimDeck {
id: number;
name: string;
commander_name: string;
color_identity: string[];
}
export interface SimPlayerResult {
seat: number;
deckName: string;
wins: number;
winRate: number;
avgTurns: number;
avgDamageDealt: number;
avgSpellsCast: number;
avgCreaturesPlayed: number;
avgRemovalUsed: number;
avgRampPlayed: number;
avgCardsDrawn: number;
avgMaxBoardSize: number;
}
export interface SimResult {
playerCount: number;
totalGames: number;
players: SimPlayerResult[];
elapsedSeconds: number;
}
+82
View File
@@ -0,0 +1,82 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
bg: {
base: '#12141e',
zone: '#1c2034',
panel: '#161820',
bar: '#1a1e30',
phase: '#1e2338',
hand: '#1a2030',
},
accent: {
blue: '#3c82dc',
think: '#64b4ff',
purple: '#7060c0',
},
life: {
high: '#50c87a',
med: '#dcb432',
low: '#dc3c3c',
},
seat: {
0: '#4696ff',
1: '#ff6450',
2: '#50d278',
3: '#dcaa32',
},
card: {
bg: '#2a3050',
hand: '#2a3868',
selected: '#3a4878',
},
border: {
zone: '#3a4060',
sep: '#2a2e48',
btn: '#4060a0',
overlay: '#4060c0',
'btn-hover': '#6080d0',
'btn-primary': '#4080d0',
'hand-card': '#5060a0',
chip: '#444444',
},
phase: {
tab: '#252a42',
text: '#888888',
},
btn: {
primary: '#2a5090',
},
text: {
body: '#e8e8e8',
muted: '#888888',
system: '#555566',
'zone-label': '#666666',
status: '#777788',
sub: '#aaaaaa',
btn: '#cccccc',
},
highlight: {
gold: '#ffdc32',
commander: '#ffd200',
},
},
keyframes: {
pulse: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.7' },
},
},
animation: {
pulse: 'pulse 1s ease-in-out infinite',
},
},
},
plugins: [],
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
// Dev-only proxy — in production FastAPI serves the built assets
// and handles /api and /ws routes directly.
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})
+163 -1944
View File
File diff suppressed because it is too large. Load diff
+238 -8524
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -240,12 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
dist[action.value] = count
total = len(labels)
print(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
print("─" * 43)
log.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
log.info("─" * 43)
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
print(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
print(f"{'─' * 43}")
print(f" {'TOTAL':<23} {total:>8}")
log.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
log.info(f"{'─' * 43}")
log.info(f" {'TOTAL':<23} {total:>8}")
return dist
+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)
print(f"\n{'Outcome':<25} {'Count':>8}")
print("─" * 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]):
print(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)
print(f"\n{'Playstyle':<25} {'Count':>8}")
print("─" * 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]):
print(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:
print("\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")
print("\nDataset build complete.")
logger.info("\nDataset build complete.")
if __name__ == "__main__":
+344
View File
@@ -0,0 +1,344 @@
"""
update_weights.py
=================
Outcome-weighted reinforcement learning for Commander AI card weights.
Reads all decisions_*.jsonl files produced by DeepSeekBrain.flush_log(),
applies a gradient-free reward nudge to each weight key based on the
normalised reward signal (-1.0 to +1.0) stamped on every decision entry,
then saves the updated weights to learned_weights.json via save_weights().
Usage
-----
# Basic run (reads logs/decisions/, updates learned_weights.json)
python -m ml.scripts.update_weights
# Custom paths
python -m ml.scripts.update_weights \\
--log-dir logs/decisions \\
--weights-out src/commander_ai_lab/sim/learned_weights.json \\
--lr 0.05 \\
--min-games 10
# Dry-run (print proposed changes, don't write)
python -m ml.scripts.update_weights --dry-run
# Reset to defaults then exit
python -m ml.scripts.update_weights --reset
Algorithm
---------
For each decision entry that has a non-null game_result:
delta = learning_rate * reward * action_weight_for(action)
Each weight key is nudged by the mean delta across all decisions that
touched that key. Keys with no decisions are left unchanged.
All values are clamped to [-20.0, 20.0] by save_weights().
Reward is the normalised life-delta from flush_log():
reward = (my_final_life - avg_opp_final_life) / 40.0 in [-1.0, 1.0]
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import sys
from collections import defaultdict
from pathlib import Path
from typing import Iterator
# ---------------------------------------------------------------------------
# Allow running as a script without installing the package
# ---------------------------------------------------------------------------
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT / "src") not in sys.path:
sys.path.insert(0, str(_REPO_ROOT / "src"))
from commander_ai_lab.sim.rules import (
AI_DEFAULT_WEIGHTS,
load_weights,
reset_weights,
save_weights,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("update_weights")
# ---------------------------------------------------------------------------
# Action → weight key mapping
# Maps each VALID_ACTION to the weight(s) it most directly influences.
# A single action may touch multiple keys (listed as a tuple).
# ---------------------------------------------------------------------------
ACTION_WEIGHT_MAP: dict[str, tuple[str, ...]] = {
"play_land": (), # no weight key — always correct
"cast_ramp": ("spell_ramp",),
"cast_removal": ("spell_destroy",),
"cast_board_wipe": ("spell_counter",), # reuse counter as board-control proxy
"cast_creature": ("card_ptBonus",),
"cast_spell": ("spell_enchantment", "spell_draw"),
"attack_all": ("kw_haste", "kw_flying"), # aggression / evasion proxy
"attack_safe": ("kw_flying", "kw_trample"),
"hold": (), # no weight key — passing is neutral
}
# ---------------------------------------------------------------------------
# JSONL helpers
# ---------------------------------------------------------------------------
def _iter_log_entries(log_dir: str) -> Iterator[dict]:
"""Yield every entry from all decisions_*.jsonl files in log_dir."""
log_path = Path(log_dir)
if not log_path.exists():
logger.warning("Log directory not found: %s", log_dir)
return
files = sorted(log_path.glob("decisions_*.jsonl"))
if not files:
logger.warning("No decisions_*.jsonl files found in %s", log_dir)
return
logger.info("Found %d log file(s) in %s", len(files), log_dir)
for fpath in files:
with open(fpath, encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except json.JSONDecodeError as exc:
logger.warning("Skipping malformed line %d in %s: %s", lineno, fpath.name, exc)
# ---------------------------------------------------------------------------
# Core learning loop
# ---------------------------------------------------------------------------
def compute_weight_updates(
log_dir: str,
current_weights: dict[str, float],
learning_rate: float = 0.05,
min_games: int = 1,
) -> tuple[dict[str, float], dict]:
"""
Read decision logs and compute updated weights.
Returns
-------
updated_weights : dict[str, float]
New weight dict after applying mean reward nudges.
stats : dict
Diagnostic info (games seen, decisions processed, keys changed, etc.)
"""
# Accumulators: key → list of (reward * lr) deltas
key_deltas: dict[str, list[float]] = defaultdict(list)
total_entries = 0
labelled_entries = 0 # entries with non-null game_result
game_ids_seen: set[str] = set()
action_counts: dict[str, int] = defaultdict(int)
for entry in _iter_log_entries(log_dir):
total_entries += 1
game_result = entry.get("game_result")
if not game_result:
continue # no outcome stamped — skip (pre-flush or incomplete game)
labelled_entries += 1
game_ids_seen.add(entry.get("game_id", "unknown"))
reward: float = float(game_result.get("reward", 0.0))
action: str = entry.get("decision", {}).get("action", "hold")
action_counts[action] += 1
weight_keys = ACTION_WEIGHT_MAP.get(action, ())
for key in weight_keys:
if key in current_weights:
key_deltas[key].append(learning_rate * reward)
num_games = len(game_ids_seen)
logger.info(
"Processed %d entries | %d labelled | %d unique games",
total_entries, labelled_entries, num_games,
)
if num_games < min_games:
logger.warning(
"Only %d game(s) found — minimum is %d. Skipping update.",
num_games, min_games,
)
return dict(current_weights), {
"skipped": True,
"reason": f"need {min_games} games, have {num_games}",
"total_entries": total_entries,
"labelled_entries": labelled_entries,
"games": num_games,
}
# Apply mean delta per key
updated = dict(current_weights)
changed_keys: dict[str, tuple[float, float]] = {} # key → (old, new)
for key, deltas in key_deltas.items():
if not deltas:
continue
mean_delta = sum(deltas) / len(deltas)
old_val = updated[key]
new_val = round(old_val + mean_delta, 6)
updated[key] = new_val
if abs(new_val - old_val) >= 0.0001:
changed_keys[key] = (old_val, new_val)
stats = {
"skipped": False,
"total_entries": total_entries,
"labelled_entries": labelled_entries,
"games": num_games,
"keys_changed": len(changed_keys),
"changes": changed_keys,
"action_counts": dict(action_counts),
}
return updated, stats
def print_diff(stats: dict, current: dict, updated: dict) -> None:
"""Pretty-print weight changes to stdout."""
changes = stats.get("changes", {})
if not changes:
print(" (no weight changes above threshold)")
return
col_w = max(len(k) for k in changes) + 2
print(f" {'Key':<{col_w}} {'Before':>10} {'After':>10} {'Delta':>10}")
print(" " + "-" * (col_w + 36))
for key, (old, new) in sorted(changes.items(), key=lambda x: abs(x[1][1] - x[1][0]), reverse=True):
delta = new - old
arrow = "▲" if delta > 0 else "▼"
print(f" {key:<{col_w}} {old:>10.4f} {new:>10.4f} {arrow} {abs(delta):>8.4f}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _default_log_dir() -> str:
return str(_REPO_ROOT / "logs" / "decisions")
def _default_weights_out() -> str:
return str(_REPO_ROOT / "src" / "commander_ai_lab" / "sim" / "learned_weights.json")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Update AI card weights from simulation decision logs."
)
parser.add_argument(
"--log-dir",
default=_default_log_dir(),
help="Directory containing decisions_*.jsonl files (default: logs/decisions/)",
)
parser.add_argument(
"--weights-out",
default=_default_weights_out(),
help="Path to write learned_weights.json (default: src/.../sim/learned_weights.json)",
)
parser.add_argument(
"--lr",
type=float,
default=0.05,
help="Learning rate — how much each game nudges a weight (default: 0.05)",
)
parser.add_argument(
"--min-games",
type=int,
default=10,
help="Minimum number of completed games required before updating (default: 10)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print proposed changes without writing learned_weights.json",
)
parser.add_argument(
"--reset",
action="store_true",
help="Delete learned_weights.json and restore AI_DEFAULT_WEIGHTS, then exit",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show action distribution and per-key delta counts",
)
args = parser.parse_args(argv)
# ── Reset mode ──────────────────────────────────────────────
if args.reset:
reset_weights(args.weights_out)
print(f"Reset: deleted {args.weights_out} (if it existed). Defaults restored.")
return 0
# ── Load current weights ─────────────────────────────────────
current = load_weights(args.weights_out)
logger.info("Loaded %d weight keys from %s", len(current), args.weights_out)
# ── Compute updates ──────────────────────────────────────────
updated, stats = compute_weight_updates(
log_dir=args.log_dir,
current_weights=current,
learning_rate=args.lr,
min_games=args.min_games,
)
# ── Report ───────────────────────────────────────────────────
print()
print("=" * 60)
print(" Commander AI — Weight Update Report")
print("=" * 60)
print(f" Log dir : {args.log_dir}")
print(f" Weights out: {args.weights_out}")
print(f" Games : {stats['games']}")
print(f" Entries : {stats['total_entries']} ({stats['labelled_entries']} labelled)")
print(f" LR : {args.lr}")
print()
if stats.get("skipped"):
print(f" SKIPPED — {stats['reason']}")
print()
return 0
print(f" Keys changed: {stats['keys_changed']}")
print()
print_diff(stats, current, updated)
print()
if args.verbose:
print(" Action distribution:")
for action, count in sorted(stats["action_counts"].items(), key=lambda x: -x[1]):
print(f" {action:<20} {count:>6} decisions")
print()
# ── Write ────────────────────────────────────────────────────
if args.dry_run:
print(" DRY RUN — no file written.")
else:
out_path = save_weights(updated, args.weights_out)
print(f" Saved updated weights to: {out_path}")
print("=" * 60)
print()
return 0
if __name__ == "__main__":
sys.exit(main())
+6 -2
View File
@@ -16,6 +16,10 @@ The network is designed for:
import os
import sys
from pathlib import Path
import logging
log = logging.getLogger("commander_ai_lab.ml.training")
# Conditional torch import — graceful failure if not installed
try:
@@ -25,7 +29,7 @@ try:
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
print("[ML] PyTorch not installed. Install with: pip install torch")
log.warning("PyTorch not installed. Install with: pip install torch")
import numpy as np
@@ -204,7 +208,7 @@ def save_checkpoint(
"dropout": TRAINING_CONFIG.dropout,
},
}, path)
print(f"[ML] Checkpoint saved: {path}")
log.info(f"Checkpoint saved: {path}")
def load_checkpoint(path: str, device: str = "cpu"):
+26 -27
View File
@@ -49,8 +49,7 @@ from ml.training.policy_network import (
PolicyNetwork, save_checkpoint, load_checkpoint,
)
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.trainer")
logger = logging.getLogger("commander_ai_lab.ml.training")
# ══════════════════════════════════════════════════════════
@@ -502,40 +501,40 @@ def evaluate_model(
def print_evaluation(results: Dict):
"""Pretty-print evaluation results."""
print()
print("=" * 65)
print(" Test Set Evaluation")
print("=" * 65)
print(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
print(f" Total Samples: {results['total_samples']:,}")
print()
print(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
print(" " + "-" * 55)
logger.info("")
logger.info("=" * 65)
logger.info(" Test Set Evaluation")
logger.info("=" * 65)
logger.info(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
logger.info(f" Total Samples: {results['total_samples']:,}")
logger.info("")
logger.info(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
logger.info(" " + "-" * 55)
for action_name, m in results["per_class"].items():
print(
logger.info(
f" {action_name:<20} {m['support']:>8} "
f"{m['precision']:>7.3f} {m['recall']:>7.3f} {m['f1']:>7.3f}"
)
print(" " + "-" * 55)
logger.info(" " + "-" * 55)
# Confusion matrix
print()
print(" Confusion Matrix (rows=true, cols=predicted):")
logger.info("")
logger.info(" Confusion Matrix (rows=true, cols=predicted):")
header = " " + " " * 18
for c in range(NUM_ACTIONS):
short_name = IDX_TO_ACTION[c].value[:6]
header += f" {short_name:>6}"
print(header)
logger.info(header)
for r in range(NUM_ACTIONS):
row_name = IDX_TO_ACTION[r].value[:16]
row_str = f" {row_name:<18}"
for c in range(NUM_ACTIONS):
row_str += f" {results['confusion_matrix'][r][c]:>6}"
print(row_str)
print()
logger.info(row_str)
logger.info("")
# ══════════════════════════════════════════════════════════
@@ -605,8 +604,8 @@ def main():
args = parser.parse_args()
if not TORCH_AVAILABLE:
print("PyTorch not installed. Install with:")
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
logger.info("PyTorch not installed. Install with:")
logger.info(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
sys.exit(1)
device = args.device or get_best_device()
@@ -621,13 +620,13 @@ def main():
if args.eval_only:
# Evaluation only
if not os.path.exists(test_path):
print(f"Test data not found: {test_path}")
print("Run: python -m ml.scripts.ml_cli build")
logger.info(f"Test data not found: {test_path}")
logger.info("Run: python -m ml.scripts.ml_cli build")
sys.exit(1)
ckpt_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
if not os.path.exists(ckpt_path):
print(f"Checkpoint not found: {ckpt_path}")
logger.info(f"Checkpoint not found: {ckpt_path}")
sys.exit(1)
test_states, test_labels = load_npz_split(test_path)
@@ -641,15 +640,15 @@ def main():
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(results, f, indent=2)
print(f" Results saved: {eval_path}")
logger.info(f" Results saved: {eval_path}")
return
# Training mode
for path, label in [(train_path, "train"), (val_path, "val")]:
if not os.path.exists(path):
print(f"{label} data not found: {path}")
print("Build the dataset first:")
print(" python -m ml.scripts.ml_cli build --results-dir results/")
logger.info(f"{label} data not found: {path}")
logger.info("Build the dataset first:")
logger.info(" python -m ml.scripts.ml_cli build --results-dir results/")
sys.exit(1)
train_states, train_labels = load_npz_split(train_path)
+47
View File
@@ -0,0 +1,47 @@
"""Pydantic models for Commander AI Lab API."""
from models.requests import (
StartRequest,
ImportUrlRequest,
ImportTextRequest,
MetaFetchRequest,
CreateDeckRequest,
UpdateDeckRequest,
AddDeckCardRequest,
PatchDeckCardRequest,
BulkAddRequest,
BulkAddRecommendedRequest,
DeckGenerationSourceConfig,
DeckGenerationRequest,
DeckGenV3Request,
DeckGenV3SubstituteRequest,
)
from models.responses import (
StartResponse,
StatusResponse,
DeckInfo,
GeneratedDeckCard,
)
__all__ = [
# Requests
"StartRequest",
"ImportUrlRequest",
"ImportTextRequest",
"MetaFetchRequest",
"CreateDeckRequest",
"UpdateDeckRequest",
"AddDeckCardRequest",
"PatchDeckCardRequest",
"BulkAddRequest",
"BulkAddRecommendedRequest",
"DeckGenerationSourceConfig",
"DeckGenerationRequest",
"DeckGenV3Request",
"DeckGenV3SubstituteRequest",
# Responses
"StartResponse",
"StatusResponse",
"DeckInfo",
"GeneratedDeckCard",
]
+175
View File
@@ -0,0 +1,175 @@
"""Pydantic request models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartRequest(BaseModel):
decks: list[str]
numGames: int = 100
threads: int = 4
seed: Optional[int] = None
clock: int = 300
deckSources: Optional[list[Optional[dict]]] = None
useLearnedPolicy: bool = False
policyStyle: str = "midrange"
policyGreedy: bool = False
aiSimplified: bool = False
aiThinkTimeMs: int = -1
maxQueueDepth: int = -1
class ImportUrlRequest(BaseModel):
url: str
class ImportTextRequest(BaseModel):
text: str
commander: Optional[str] = None
class MetaFetchRequest(BaseModel):
commander: str
class CreateDeckRequest(BaseModel):
name: str
commander_scryfall_id: Optional[str] = ""
commander_name: Optional[str] = ""
color_identity: Optional[list] = []
strategy_tag: Optional[str] = ""
class UpdateDeckRequest(BaseModel):
name: Optional[str] = None
commander_scryfall_id: Optional[str] = None
commander_name: Optional[str] = None
color_identity: Optional[list] = None
strategy_tag: Optional[str] = None
class AddDeckCardRequest(BaseModel):
scryfall_id: str
card_name: Optional[str] = ""
quantity: Optional[int] = 1
is_commander: Optional[int] = 0
role_tag: Optional[str] = ""
class PatchDeckCardRequest(BaseModel):
quantity: Optional[int] = None
role_tag: Optional[str] = None
class BulkAddRequest(BaseModel):
cards: list[dict]
respect_ratios: Optional[bool] = False
class BulkAddRecommendedRequest(BaseModel):
source: str = "collection"
only_owned: Optional[bool] = True
respect_ratios: Optional[bool] = False
types: Optional[list[str]] = None
roles: Optional[list[str]] = None
class DeckGenerationSourceConfig(BaseModel):
use_archidekt: bool = True
use_edhrec: bool = True
use_moxfield: bool = False
use_mtggoldfish: bool = False
archidekt_url: Optional[str] = ""
moxfield_url: Optional[str] = ""
mtggoldfish_url: Optional[str] = ""
class DeckGenerationRequest(BaseModel):
commander_name: Optional[str] = ""
commander_scryfall_id: Optional[str] = ""
color_identity: Optional[list[str]] = None
sources: Optional[DeckGenerationSourceConfig] = None
target_land_count: int = 37
target_instant_count: int = 10
target_sorcery_count: int = 8
target_artifact_count: int = 10
target_enchantment_count: int = 8
target_creature_count: int = 25
target_planeswalker_count: int = 2
only_cards_in_collection: bool = False
allow_proxies: bool = True
deck_name: Optional[str] = ""
class DeckGenV3Request(BaseModel):
commander_name: str = ""
strategy: str = ""
target_bracket: int = 3
budget_usd: Optional[float] = None
budget_mode: str = "total"
omit_cards: list[str] = []
use_collection: bool = True
run_substitution: bool = True
model: Optional[str] = None
deck_name: Optional[str] = ""
# ══════════════════════════════════════════════════════════════
# Deckgen requests (moved from routes/deckgen.py)
# ══════════════════════════════════════════════════════════════
class DeckResearchRequest(BaseModel):
deck_id: int # Deck to research
goal: Optional[str] = "Identify weaknesses and suggest upgrades"
budget_usd: Optional[float] = None
omit_cards: Optional[list[str]] = []
use_collection: bool = True
class DeckGenerateAIRequest(BaseModel):
commander: str
budget_usd: Optional[float] = None
budget_mode: str = "total" # "total" or "per_card"
omit_cards: Optional[list[str]] = []
use_collection: bool = True
# ══════════════════════════════════════════════════════════════
# Coach requests (moved from routes/coach.py)
# ══════════════════════════════════════════════════════════════
class CoachRequestBody(BaseModel):
goals: Optional[dict] = None
class CoachChatMessage(BaseModel):
role: str # "user" or "assistant"
content: str
class CoachChatRequest(BaseModel):
deck_id: str
messages: list[dict] # conversation history [{role, content}]
goals: Optional[dict] = None
stream: Optional[bool] = False
class CoachApplyRequest(BaseModel):
session_id: str
deck_id: int # numeric deck ID in the DB
accepted_cuts: list[str] = [] # card names to remove
accepted_adds: list[str] = [] # card names to add
class CoachGoalsRequest(BaseModel):
target_power_level: Optional[int] = None # 1-10
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
budget: Optional[str] = None # budget, medium, no-limit
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
class DeckGenV3SubstituteRequest(BaseModel):
card_name: str
substitute_name: str
+45
View File
@@ -0,0 +1,45 @@
"""Pydantic response models for Commander AI Lab API."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class StartResponse(BaseModel):
batchId: str
status: str = "started"
message: str = ""
class StatusResponse(BaseModel):
batchId: str = ""
running: bool = False
completed: int = 0
total: int = 0
threads: int = 0
elapsedMs: int = 0
error: Optional[str] = None
simsPerSec: float = 0.0
run_id: str = ""
games_completed: int = 0
total_games: int = 0
current_decks: list = []
class DeckInfo(BaseModel):
name: str
filename: str
class GeneratedDeckCard(BaseModel):
scryfall_id: str = ""
name: str = ""
type_line: str = ""
mana_cost: str = ""
cmc: float = 0
card_type: str = ""
roles: list[str] = []
source: str = "collection"
quantity: int = 1
image_url: str = ""
owned_qty: int = 0
is_proxy: bool = False
+101
View File
@@ -0,0 +1,101 @@
"""Runtime configuration, state classes, and static data for Commander AI Lab."""
from __future__ import annotations
import json
import logging
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
log = logging.getLogger("commander_ai_lab.api")
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
class Config:
"""Runtime configuration -- set via CLI args or env vars."""
forge_jar: str = ""
forge_dir: str = ""
forge_decks_dir: str = ""
lab_jar: str = ""
precon_dir: str = ""
results_dir: str = "results"
port: int = 8080
ximilar_api_key: str = ""
pplx_api_key: str = ""
CFG = Config()
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
class BatchState:
def __init__(self, batch_id: str, total_games: int, threads: int):
self.batch_id = batch_id
self.total_games = total_games
self.threads = threads
self.completed_games = 0
self.running = True
self.start_time = datetime.now()
self.elapsed_ms = 0
self.result_path: Optional[str] = None
self.error: Optional[str] = None
self.process: Optional[subprocess.Popen] = None
self.log_lines: list = []
self.sims_per_sec: float = 0.0
active_batches: dict[str, BatchState] = {}
COMMANDER_META: dict = {}
BUILTIN_COMMANDERS: dict = {
"Edgar Markov": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Atraxa, Praetors' Voice": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","G"]}],
"Korvold, Fae-Cursed King": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Muldrotha, the Gravetide": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["U","B","G"]}],
"The Ur-Dragon": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["W","U","B","R","G"]}],
"Yuriko, the Tiger's Shadow": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["U","B"]}],
"Krenko, Mob Boss": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["R"]}],
"Meren of Clan Nel Toth": [{"source": "edhrec", "archetype": "midrange", "colorIdentity": ["B","G"]}],
"Prossh, Skyraider of Kher": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["B","R","G"]}],
"Kaalia of the Vast": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["W","B","R"]}],
"Talrand, Sky Summoner": [{"source": "edhrec", "archetype": "control", "colorIdentity": ["U"]}],
"Omnath, Locus of Creation": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","R","G"]}],
"Teysa Karlov": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","B"]}],
"Lathril, Blade of the Elves": [{"source": "edhrec", "archetype": "aggro", "colorIdentity": ["B","G"]}],
"Breya, Etherium Shaper": [{"source": "edhrec", "archetype": "combo", "colorIdentity": ["W","U","B","R"]}],
}
def load_commander_meta() -> None:
"""Load commander meta mapping from file or fall back to builtins."""
global COMMANDER_META
meta_path = Path(__file__).parent.parent / "commander-meta.json"
if meta_path.exists():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META = json.load(f)
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META = BUILTIN_COMMANDERS
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
# ══════════════════════════════════════════════════════════════
# AI Profiles
# ══════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {"name": "default", "description": "Balanced \u2014 Forge's default AI behavior", "aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5, "boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5},
"aggro": {"name": "aggro", "description": "Aggressive \u2014 attacks early, prioritizes damage", "aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3, "boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1},
"control": {"name": "control", "description": "Control \u2014 defensive, removal-heavy, card advantage", "aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9, "boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9},
"combo": {"name": "combo", "description": "Combo \u2014 ramps, digs for pieces, assembles combos", "aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4, "boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7},
"midrange": {"name": "midrange", "description": "Midrange \u2014 flexible, strong board presence, value-oriented", "aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6, "boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5},
}
+121 -79
View File
@@ -22,11 +22,15 @@ Configuration:
import argparse
import json
import logging
import logging.handlers
import os
import sys
import time
import urllib.request
import urllib.error
from datetime import datetime, timedelta
from pathlib import Path
# ── Default Config ──────────────────────────────────────────────
@@ -51,10 +55,48 @@ DEFAULT_CONFIG = {
}
def log(msg):
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] {msg}")
sys.stdout.flush()
# ── Logging ──────────────────────────────────────────────────
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
def setup_overnight_logging() -> logging.Logger:
"""Configure logging for the overnight runner.
Outputs to both console and a rotating log file so that
unattended runs always have a persistent record.
"""
_LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("commander_ai_lab.overnight")
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
fmt = logging.Formatter(
"%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(fmt)
logger.addHandler(console)
file_handler = logging.handlers.RotatingFileHandler(
_LOG_DIR / "overnight-run.log",
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
encoding="utf-8",
)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(fmt)
logger.addHandler(file_handler)
return logger
log = setup_overnight_logging()
def api_get(base_url, path):
@@ -129,8 +171,8 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
while True:
# Hard deadline check — stop waiting if we've run out of time
if deadline and time.time() >= deadline:
log(f" Time limit reached while batch {batch_id} was running.")
log(f" The batch will continue on the server but we're moving on.")
log.info(f" Time limit reached while batch {batch_id} was running.")
log.info(f" The batch will continue on the server but we're moving on.")
return {"completed": 0, "timed_out": True}
try:
@@ -144,12 +186,12 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
elapsed = status.get("elapsedMs", 0)
if error:
log(f" Batch {batch_id} ERROR: {error}")
log.error(f" Batch {batch_id} ERROR: {error}")
return None
if not running and completed > 0:
elapsed_s = elapsed / 1000 if elapsed else 0
log(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
log.info(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
try:
result = api_get(base_url, f"/api/lab/result?batchId={batch_id}")
return result
@@ -160,15 +202,15 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
if not running and completed == 0:
stall_checks += 1
if stall_checks > 20: # 5 min with no progress
log(f" Batch {batch_id} never started. Aborting.")
log.info(f" Batch {batch_id} never started. Aborting.")
return None
# Progress log with ETA
if deadline:
mins_left = (deadline - time.time()) / 60
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
else:
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
# Track stall
if completed == last_completed:
@@ -180,9 +222,9 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
except Exception as e:
consecutive_errors += 1
if consecutive_errors > 10:
log(f" Lost connection to server after 10 retries. Aborting batch.")
log.info(f" Lost connection to server after 10 retries. Aborting batch.")
return None
log(f" Connection error (retry {consecutive_errors}/10): {e}")
log.info(f" Connection error (retry {consecutive_errors}/10): {e}")
time.sleep(poll_interval)
@@ -214,26 +256,26 @@ def wait_for_training(base_url):
error = status.get("error")
if error:
log(f" Training ERROR: {error}")
log.error(f" Training ERROR: {error}")
return False
if not running and phase in ("done", "idle") and epoch > 0:
result = status.get("result", {})
log(f" Training complete!")
log.info(f" Training complete!")
if result:
log(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log(f" Model saved: {result.get('model_path', '?')}")
log.info(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log.info(f" Model saved: {result.get('model_path', '?')}")
return True
if phase == "training" and total > 0:
metrics = status.get("metrics", {})
val_acc = metrics.get("val_accuracy", "?") if metrics else "?"
log(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
log.info(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
else:
log(f" [{phase}] {message}")
log.info(f" [{phase}] {message}")
except Exception as e:
log(f" Connection error: {e}")
log.info(f" Connection error: {e}")
time.sleep(poll_interval)
@@ -253,46 +295,46 @@ def run_overnight(cfg):
"""Main overnight pipeline."""
base_url = cfg["lab_url"]
log("=" * 60)
log(" Commander AI Lab — Overnight AFK Runner")
log("=" * 60)
log.info("=" * 60)
log.info(" Commander AI Lab — Overnight AFK Runner")
log.info("=" * 60)
# ── Verify server ────────────────────────────────────────
log("Checking lab server...")
log.info("Checking lab server...")
if not check_server(base_url):
log(f"ERROR: Cannot reach lab server at {base_url}")
log("Start the lab first: start-lab.bat")
log.error(f"ERROR: Cannot reach lab server at {base_url}")
log.info("Start the lab first: start-lab.bat")
return False
log(f"Server OK at {base_url}")
log.info(f"Server OK at {base_url}")
# ── Get decks ─────────────────────────────────────────────
available_decks = get_available_decks(base_url)
log(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
log.info(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
engine = cfg["engine"]
if engine == "deepseek":
decks = cfg["ds_decks"] if cfg["ds_decks"] else available_decks
if not decks:
log("ERROR: No decks available. Create decks in the Deck Builder first.")
log.error("ERROR: No decks available. Create decks in the Deck Builder first.")
return False
log(f"Using DeepSeek engine with {len(decks)} deck(s)")
log.info(f"Using DeepSeek engine with {len(decks)} deck(s)")
else:
decks = cfg["java_decks"]
if len(decks) != 3:
log("ERROR: Java engine requires exactly 3 deck names.")
log("Set java_decks in the config or use --decks deck1 deck2 deck3")
log.error("ERROR: Java engine requires exactly 3 deck names.")
log.info("Set java_decks in the config or use --decks deck1 deck2 deck3")
return False
log(f"Using Java/Forge engine with decks: {decks}")
log.info(f"Using Java/Forge engine with decks: {decks}")
# ── ML logging ────────────────────────────────────────────
log("Enabling ML logging...")
log.info("Enabling ML logging...")
try:
api_post(base_url, "/api/ml/toggle?enable=true", {})
log("ML logging enabled")
log.info("ML logging enabled")
except Exception as e:
log(f"WARNING: Could not enable ML logging: {e}")
log("(DeepSeek batches always log ML data regardless)")
log.warning(f"WARNING: Could not enable ML logging: {e}")
log.info("(DeepSeek batches always log ML data regardless)")
# Try alternate form
try:
api_get(base_url, "/api/ml/toggle?enable=true")
@@ -301,7 +343,7 @@ def run_overnight(cfg):
# ── Initial data stats ────────────────────────────────────
decisions_before, files_before = get_ml_data_summary(base_url)
log(f"Current ML data: {decisions_before} decisions in {files_before} files")
log.info(f"Current ML data: {decisions_before} decisions in {files_before} files")
# ── Run batches ───────────────────────────────────────────
games_per_batch = cfg["games_per_batch"]
@@ -315,32 +357,32 @@ def run_overnight(cfg):
total_games = 0
total_decisions = 0
log("")
log(f"Starting batch loop:")
log.info("")
log.info(f"Starting batch loop:")
if num_batches > 0:
log(f" Mode: {num_batches} batches × {games_per_batch} games")
log.info(f" Mode: {num_batches} batches × {games_per_batch} games")
else:
log(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
log(f" Engine: {engine}")
log(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log("")
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
log.info(f" Engine: {engine}")
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
log.info("")
while True:
# Check stopping conditions
if num_batches > 0 and batch_num >= num_batches:
log(f"Reached target of {num_batches} batches. Stopping.")
log.info(f"Reached target of {num_batches} batches. Stopping.")
break
elapsed = time.time() - start_time
remaining = deadline - time.time()
if remaining < 120: # need at least 2 min for a batch
log(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
log.info(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
break
batch_num += 1
elapsed_str = str(timedelta(seconds=int(elapsed)))
remaining_str = str(timedelta(seconds=int(remaining)))
log(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
log.info(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
try:
if engine == "deepseek":
@@ -352,12 +394,12 @@ def run_overnight(cfg):
)
if not batch_id:
log(" ERROR: Failed to start batch (no batchId returned)")
log(" Waiting 60s before retry...")
log.error(" ERROR: Failed to start batch (no batchId returned)")
log.info(" Waiting 60s before retry...")
time.sleep(60)
continue
log(f" Started batch {batch_id}")
log.info(f" Started batch {batch_id}")
result = wait_for_batch(base_url, batch_id, engine, deadline=deadline)
if result:
@@ -366,19 +408,19 @@ def run_overnight(cfg):
for deck in result.get("decks", []):
wr = deck.get("winRate", "?")
name = deck.get("deckName", "?")
log(f" {name}: {wr}% WR")
log.info(f" {name}: {wr}% WR")
else:
log(" Batch failed, continuing to next...")
log.info(" Batch failed, continuing to next...")
except Exception as e:
log(f" ERROR starting batch: {e}")
log(" Waiting 60s before retry...")
log.error(f" ERROR starting batch: {e}")
log.info(" Waiting 60s before retry...")
time.sleep(60)
continue
# Pause between batches
if pause_sec > 0:
log(f" Cooling down {pause_sec}s...")
log.info(f" Cooling down {pause_sec}s...")
time.sleep(pause_sec)
# ── Summary ───────────────────────────────────────────────
@@ -387,26 +429,26 @@ def run_overnight(cfg):
new_decisions = decisions_after - decisions_before
new_files = files_after - files_before
log("")
log("=" * 60)
log(" BATCH SIMULATION COMPLETE")
log("=" * 60)
log(f" Batches run: {batch_num}")
log(f" Total games: ~{total_games}")
log(f" New ML decisions: {new_decisions} (in {new_files} new files)")
log(f" Total ML data: {decisions_after} decisions")
log(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
log("")
log.info("")
log.info("=" * 60)
log.info(" BATCH SIMULATION COMPLETE")
log.info("=" * 60)
log.info(f" Batches run: {batch_num}")
log.info(f" Total games: ~{total_games}")
log.info(f" New ML decisions: {new_decisions} (in {new_files} new files)")
log.info(f" Total ML data: {decisions_after} decisions")
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
log.info("")
# ── Auto-train ────────────────────────────────────────────
if cfg["auto_train"]:
if decisions_after < 100:
log("Skipping training — too few decisions (need at least 100)")
log.info("Skipping training — too few decisions (need at least 100)")
return True
log("Starting ML training pipeline...")
log(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
log.info("Starting ML training pipeline...")
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
try:
start_training(
@@ -416,21 +458,21 @@ def run_overnight(cfg):
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
)
log("Training started, monitoring progress...")
log.info("Training started, monitoring progress...")
success = wait_for_training(base_url)
if success:
log("")
log("=" * 60)
log(" TRAINING COMPLETE — model updated!")
log("=" * 60)
log.info("")
log.info("=" * 60)
log.info(" TRAINING COMPLETE — model updated!")
log.info("=" * 60)
else:
log("Training finished with errors. Check the Training tab for details.")
log.info("Training finished with errors. Check the Training tab for details.")
except Exception as e:
log(f"ERROR starting training: {e}")
log.error(f"ERROR starting training: {e}")
return False
log("")
log("Overnight run finished. You can close this window.")
log.info("")
log.info("Overnight run finished. You can close this window.")
return True
+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>
+24
View File
@@ -10,8 +10,32 @@ readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
dependencies = [
"fastapi>=0.100.0",
"uvicorn[standard]>=0.23.0",
"pydantic>=2.0.0",
"httpx>=0.24.0",
"websockets>=11.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"httpx>=0.24.0",
"ruff>=0.1.0",
]
[tool.setuptools.packages.find]
where = ["src"]
[project.scripts]
commander-sim = "commander_ai_lab.lab.experiments:_cli_main"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = [".", "src"]
[tool.ruff]
line-length = 120
target-version = "py310"
+24
View File
@@ -0,0 +1,24 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test* Bench*
python_functions = test_*
# Asyncio mode for pytest-asyncio
asyncio_mode = auto
# Markers
markers =
slow: marks tests as slow (deselect with -m "not slow")
integration: marks integration tests
benchmark: marks benchmark tests
unit: marks fast unit tests
# Output
addopts =
--tb=short
-q
# Coverage (used when running: pytest --cov)
# pip install pytest-cov
# pytest --cov=src/commander_ai_lab --cov-report=term-missing
+11
View File
@@ -0,0 +1,11 @@
# Commander AI Lab — Web server dependencies (Phase 5)
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
httpx>=0.27.0 # async Scryfall art proxy
websockets>=12.0 # used by uvicorn WebSocket transport
pydantic>=2.6.0
# Dev / test
pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27.0 # TestClient uses httpx transport
+15
View File
@@ -0,0 +1,15 @@
# Core runtime dependencies
# Install with: pip install -r requirements.txt
# Or for dev tools: pip install -e ".[dev]"
fastapi>=0.100.0
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
+6
View File
@@ -0,0 +1,6 @@
"""
routes package — API routers for Commander AI Lab.
Each module defines a FastAPI APIRouter with real endpoint implementations.
All shared helpers, models, and state live in routes.shared.
"""
+671
View File
@@ -0,0 +1,671 @@
"""
routes/coach.py
===============
LLM-powered deck coaching endpoints:
GET /api/coach/status
GET /api/coach/decks
GET /api/coach/decks/{deck_id}/report
POST /api/coach/decks/{deck_id}
GET /api/coach/sessions
GET /api/coach/sessions/{session_id}
POST /api/coach/embeddings/download
GET /api/coach/embeddings/search
POST /api/coach/chat
POST /api/coach/apply
GET /api/coach/cards-like
POST /api/coach/reports/generate
Globals:
_coach_service, _coach_embeddings, _coach_llm
_deck_gen_v3, _deck_gen_v3_error
init_coach_service(), _build_deck_report_from_db()
"""
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 models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
from routes.shared import (
CFG,
log_coach, log_deckgen,
_get_db_conn,
_compute_deck_analysis,
)
router = APIRouter(tags=["coach"])
# ══════════════════════════════════════════════════════════════
# Coach Service (LLM-powered deck coaching)
# ══════════════════════════════════════════════════════════════
# Global coach instances (initialized at startup)
_coach_service = None
_coach_embeddings = None
_coach_llm = None
_deck_gen_v3 = None # V3 Deck Generator (Perplexity structured output)
_deck_gen_v3_error = None # Error message if V3 init failed
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 LLMClient
from coach.embeddings import MTGEmbeddingIndex
from coach.coach_service import CoachService
from coach.config import ensure_dirs
ensure_dirs()
_coach_llm = LLMClient()
_coach_embeddings = MTGEmbeddingIndex()
# Try to load embeddings (non-blocking — download happens on first use)
try:
from coach.config import EMBEDDINGS_NPZ
if EMBEDDINGS_NPZ.exists():
_coach_embeddings.load()
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
else:
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
except Exception as e:
log_coach.error(f" Coach: Embeddings load failed: {e}")
_coach_service = CoachService(_coach_llm, _coach_embeddings)
# Check LLM connection
llm_status = _coach_llm.check_connection()
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 Ollama on 192.168.0.240:11434)")
# Initialize V3 Deck Generator (Ollama-based)
_deck_gen_v3_error = None
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}\n{traceback.format_exc()}")
_coach_service = None
@router.get("/api/coach/status")
async def coach_status():
"""Check coach subsystem health."""
if _coach_service is None:
return {"llmConnected": False, "embeddingsLoaded": False,
"embeddingCards": 0, "deckReportsAvailable": 0,
"error": "Coach service not initialized"}
status = _coach_service.get_status()
return status.model_dump()
@router.get("/api/coach/decks")
async def coach_list_decks():
"""List all decks available for coaching (from deck builder DB)."""
conn = _get_db_conn()
rows = conn.execute(
"""SELECT d.id as deck_id, d.name as deck_name, d.commander_name,
COUNT(dc.id) as card_count
FROM decks d
LEFT JOIN deck_cards dc ON dc.deck_id = d.id
GROUP BY d.id
ORDER BY d.name"""
).fetchall()
# Also get report availability from coach service
report_ids = set()
if _coach_service:
report_ids = set(_coach_service.list_deck_reports())
decks = []
for r in rows:
deck_name = r["deck_name"]
# Check if a report exists (by deck name slug match)
has_report = any(
deck_name.lower().replace(" ", "-") == rid.lower() or
deck_name.lower() == rid.lower()
for rid in report_ids
)
decks.append({
"deck_id": r["deck_id"],
"deck_name": r["deck_name"],
"commander": r["commander_name"] or "",
"card_count": r["card_count"],
"has_report": has_report,
"report_count": 1 if has_report else 0,
"last_report_date": None,
})
return decks
@router.get("/api/coach/decks/{deck_id}/report")
async def coach_get_report(deck_id: str):
"""Get the latest DeckReport for a deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
report = _coach_service.load_deck_report(deck_id)
if report is None:
raise HTTPException(404, f"Deck report not found: {deck_id}")
return report.model_dump()
@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."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
# Load embeddings on first use if not loaded
if _coach_embeddings and not _coach_embeddings.loaded:
try:
_coach_embeddings.load(force_download=True)
except Exception as e:
log_coach.error(f" Coach: Embeddings download failed: {e}")
goals = None
if body and body.goals:
from coach.models import CoachGoals
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
# Build a fallback DeckReport from the DB if no simulation report exists
fallback_report = None
try:
fallback_report = _build_deck_report_from_db(deck_id)
except Exception as e:
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
try:
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
return session.model_dump()
except ValueError as e:
raise HTTPException(404, str(e))
except ConnectionError as e:
raise HTTPException(503, f"LLM connection failed: {e}")
except Exception as e:
raise HTTPException(500, f"Coach session failed: {e}")
def _build_deck_report_from_db(deck_slug: str):
"""
Build a lightweight DeckReport from the deck builder DB when no
simulation report exists. Allows the coach to analyze deck composition
even without simulation data.
"""
from coach.models import DeckReport, CardPerformance, DeckStructure
conn = _get_db_conn()
# --- 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
# 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
deck_id = matched_deck["id"]
deck_name = matched_deck["name"]
commander = matched_deck["commander_name"] or ""
# Parse color identity
ci_raw = matched_deck["color_identity"] or "[]"
try:
color_identity = json.loads(ci_raw) if isinstance(ci_raw, str) else ci_raw
except Exception:
color_identity = []
# Load all cards in this deck
card_rows = conn.execute(
"""SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag,
ce.type_line, ce.cmc, ce.oracle_text
FROM deck_cards dc
LEFT JOIN collection_entries ce ON LOWER(dc.card_name) = LOWER(ce.name)
WHERE dc.deck_id = ?""",
(deck_id,)
).fetchall()
cards = []
type_counts = {}
cmc_buckets = [0] * 8
land_count = 0
for cr in card_rows:
card_name = cr["card_name"] or ""
type_line = cr["type_line"] or ""
cmc = cr["cmc"] or 0
qty = cr["quantity"] or 1
role_tag = cr["role_tag"] or ""
# Build tags from type_line and role_tag
tags = []
if role_tag:
tags.append(role_tag)
if "Land" in type_line:
tags.append("land")
land_count += qty
elif "Creature" in type_line:
tags.append("creature")
elif "Instant" in type_line:
tags.append("instant")
elif "Sorcery" in type_line:
tags.append("sorcery")
elif "Artifact" in type_line:
tags.append("artifact")
elif "Enchantment" in type_line:
tags.append("enchantment")
elif "Planeswalker" in type_line:
tags.append("planeswalker")
# Type count
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
if t in type_line:
type_counts[t] = type_counts.get(t, 0) + qty
# CMC bucket
bucket = min(int(cmc), 7)
cmc_buckets[bucket] += qty
# CardPerformance with zeroed-out sim stats
cards.append(CardPerformance(
name=card_name,
drawnRate=0.0,
castRate=0.0,
impactScore=0.0,
tags=tags,
))
slug = deck_slug
return DeckReport(
deckId=slug,
commander=commander,
colorIdentity=color_identity,
cards=cards,
structure=DeckStructure(
landCount=land_count,
curveBuckets=cmc_buckets,
cardTypeCounts=type_counts,
),
)
@router.get("/api/coach/sessions")
async def coach_list_sessions(deck_id: str = None):
"""List all coaching sessions, optionally filtered by deck."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
return {"sessions": _coach_service.list_sessions(deck_id)}
@router.get("/api/coach/sessions/{session_id}")
async def coach_get_session(session_id: str):
"""Get a specific coaching session."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
session = _coach_service.load_session(session_id)
if session is None:
raise HTTPException(404, f"Session not found: {session_id}")
return session.model_dump()
@router.post("/api/coach/embeddings/download")
async def coach_download_embeddings():
"""Download and convert MTG card embeddings from HuggingFace."""
if _coach_embeddings is None:
raise HTTPException(500, "Coach service not initialized")
try:
_coach_embeddings.load(force_download=True)
return {
"success": True,
"cards": _coach_embeddings.card_count,
"message": f"Loaded {_coach_embeddings.card_count} card embeddings"
}
except Exception as e:
raise HTTPException(500, f"Failed to download embeddings: {e}")
@router.get("/api/coach/embeddings/search")
async def coach_search_similar(card: str, colors: str = None, top_n: int = 10):
"""Search for similar cards by name."""
if _coach_embeddings is None or not _coach_embeddings.loaded:
raise HTTPException(503, "Embeddings not loaded")
color_filter = list(colors.upper()) if colors else None
matches = _coach_embeddings.search_similar(
query_card=card, color_filter=color_filter, top_n=top_n
)
return {"query": card, "matches": [m.to_dict() for m in matches]}
@router.post("/api/coach/chat")
async def coach_chat(body: CoachChatRequest):
"""Multi-turn coaching chat. Sends conversation history to LLM and returns response."""
if _coach_service is None:
raise HTTPException(500, "Coach service not initialized")
# Load deck report for context
report = _coach_service.load_deck_report(body.deck_id)
# Build system prompt with report context
from coach.prompt_template import build_system_prompt
from coach.models import CoachGoals
goals = None
if body.goals:
try:
goals = CoachGoals(**body.goals)
except Exception:
goals = None
system_prompt = ""
if report:
system_prompt = build_system_prompt(report, goals)
else:
system_prompt = (
"You are an expert Magic: The Gathering Commander deck coach. "
"Answer questions about deck building, strategy, card choices, and game theory. "
"Be specific and actionable in your advice."
)
# Build messages array for LLM
messages = [{"role": "system", "content": system_prompt}]
for msg in body.messages:
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
# Call LLM with multi-turn messages
try:
import json
from urllib.request import urlopen, Request as UrlRequest
from coach.config import LLM_URL, LLM_TIMEOUT
model_name = _coach_llm._resolve_model()
llm_body = {
"model": model_name,
"messages": messages,
"temperature": 0.7,
"max_tokens": 4096,
}
if body.stream:
# SSE streaming response
llm_body["stream"] = True
async def generate():
import asyncio
def _stream():
req = UrlRequest(
f"{_coach_llm.base_url}/chat/completions",
data=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
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=LLM_TIMEOUT)
conn.request("POST", parsed.path, body=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"})
resp = conn.getresponse()
chunks = []
while True:
line = resp.readline()
if not line:
break
line = line.decode("utf-8").strip()
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
chunks.append("[DONE]")
break
chunks.append(data)
conn.close()
return chunks
loop = asyncio.get_event_loop()
chunks = await loop.run_in_executor(None, _stream)
for chunk_str in chunks:
if chunk_str == "[DONE]":
yield f"data: [DONE]\n\n"
break
try:
chunk = json.loads(chunk_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content:
# Strip think tags from streaming chunks
yield f"data: {json.dumps({'content': content})}\n\n"
except json.JSONDecodeError:
continue
return StreamingResponse(generate(), media_type="text/event-stream")
else:
# Non-streaming: regular call
req = UrlRequest(
f"{_coach_llm.base_url}/chat/completions",
data=json.dumps(llm_body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
import asyncio
loop = asyncio.get_event_loop()
def _call():
with urlopen(req, timeout=LLM_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
raw = await loop.run_in_executor(None, _call)
content = raw.get("choices", [{}])[0].get("message", {}).get("content", "")
# Strip think tags
content = _coach_llm._strip_think_tags(content)
usage = raw.get("usage", {})
return {
"content": content,
"model": raw.get("model", ""),
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
}
except Exception as e:
raise HTTPException(500, f"Chat failed: {e}")
@router.post("/api/coach/apply")
async def coach_apply_suggestions(body: CoachApplyRequest):
"""Apply accepted coaching suggestions to a deck — remove cuts, add adds."""
conn = _get_db_conn()
# Verify deck exists
deck = conn.execute("SELECT * FROM decks WHERE id = ?", (body.deck_id,)).fetchone()
if not deck:
raise HTTPException(404, f"Deck {body.deck_id} not found")
results = {"cuts": [], "adds": [], "errors": []}
# Process cuts: remove cards by name
for card_name in body.accepted_cuts:
row = conn.execute(
"SELECT id FROM deck_cards WHERE deck_id = ? AND card_name = ? LIMIT 1",
(body.deck_id, card_name)
).fetchone()
if row:
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
results["cuts"].append({"name": card_name, "status": "removed"})
else:
# Try case-insensitive
row = conn.execute(
"SELECT id, card_name FROM deck_cards WHERE deck_id = ? AND LOWER(card_name) = LOWER(?) LIMIT 1",
(body.deck_id, card_name)
).fetchone()
if row:
conn.execute("DELETE FROM deck_cards WHERE id = ?", (row["id"],))
results["cuts"].append({"name": row["card_name"], "status": "removed"})
else:
results["errors"].append({"name": card_name, "error": "Card not found in deck"})
# Process adds: look up in collection first, then Scryfall
for card_name in body.accepted_adds:
# Try to find in collection
ce = conn.execute(
"SELECT scryfall_id, name FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(card_name,)
).fetchone()
scryfall_id = None
resolved_name = card_name
if ce:
scryfall_id = ce["scryfall_id"]
resolved_name = ce["name"]
else:
# 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}",
headers={"User-Agent": "commander-ai-lab/1.0"}
)
with urlopen(scry_req, timeout=10) as resp:
card_data = json.loads(resp.read().decode("utf-8"))
scryfall_id = card_data.get("id")
resolved_name = card_data.get("name", card_name)
except Exception:
pass
if scryfall_id:
# Check if already in deck
existing = conn.execute(
"SELECT id FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?",
(body.deck_id, scryfall_id)
).fetchone()
if not existing:
conn.execute(
"INSERT INTO deck_cards (deck_id, scryfall_id, card_name, quantity) VALUES (?, ?, ?, 1)",
(body.deck_id, scryfall_id, resolved_name)
)
results["adds"].append({"name": resolved_name, "scryfall_id": scryfall_id, "status": "added"})
else:
results["adds"].append({"name": resolved_name, "status": "already_in_deck"})
else:
results["errors"].append({"name": card_name, "error": "Could not resolve card"})
conn.execute("UPDATE decks SET updated_at = datetime('now') WHERE id = ?", (body.deck_id,))
conn.commit()
results["total_cuts"] = len(results["cuts"])
results["total_adds"] = len([a for a in results["adds"] if a["status"] == "added"])
return results
@router.get("/api/coach/cards-like")
async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
"""Find cards similar to a given card using embeddings. UI-friendly version."""
if _coach_embeddings is None or not _coach_embeddings.loaded:
raise HTTPException(503, "Embeddings not loaded")
color_filter = list(colors.upper()) if colors else None
matches = _coach_embeddings.search_similar(
query_card=card, color_filter=color_filter, top_n=top_n
)
conn = _get_db_conn()
results = []
for m in matches:
# Check if card is in collection
owned = conn.execute(
"SELECT SUM(quantity) as qty FROM collection_entries WHERE LOWER(name) = LOWER(?)",
(m.name,)
).fetchone()
owned_qty = (owned["qty"] or 0) if owned else 0
# Get image URL and price
ce = conn.execute(
"SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
(m.name,)
).fetchone()
results.append({
"name": m.name,
"similarity": round(m.similarity, 4),
"types": m.types,
"mana_value": m.mana_value,
"mana_cost": m.mana_cost,
"text": m.text[:200] if m.text else "",
"owned_qty": owned_qty,
"image_url": ce["image_url"] if ce else None,
"tcg_price": ce["tcg_price"] if ce else None,
})
return {"query": card, "results": results}
@router.post("/api/coach/reports/generate")
async def coach_generate_reports():
"""Rebuild all deck reports from batch result JSONs in results/."""
try:
from coach.report_generator import generate_deck_reports
lab_root = Path(__file__).parent
results_dir = str(lab_root / CFG.results_dir)
reports_dir = str(lab_root / "deck-reports")
updated = generate_deck_reports(results_dir, reports_dir)
return {
"status": "ok",
"decksUpdated": updated,
"count": len(updated),
"message": f"Generated reports for {len(updated)} decks" if updated else "No batch results found",
}
except Exception as e:
raise HTTPException(500, f"Report generation failed: {e}")
+487
View File
@@ -0,0 +1,487 @@
"""
routes/collection.py
====================
Collection management endpoints:
GET /api/collection
GET /api/collection/export
GET /api/collection/sets
GET /api/collection/keywords
POST /api/collection/import
GET /api/collection/{cardId}
PATCH /api/collection/{cardId}
GET /api/cache/scryfall
DELETE /api/cache/scryfall
POST /api/cache/scryfall/evict
"""
from __future__ import annotations
import csv
import io
import json
import re
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from routes.shared import (
CFG,
_get_db_conn,
_row_to_dict,
_add_image_url,
_build_collection_filters,
_detect_card_roles,
_classify_card_type,
_enrich_from_scryfall,
_fetch_scryfall_api,
_parse_finish,
_parse_text_line,
_parse_csv_content,
_auto_infer_mapping,
_scryfall_cache,
_scryfall_rate_limit,
_snake_to_camel,
VALID_SORT_FIELDS,
_JSON_FIELDS,
SCRYFALL_CACHE_DB_PATH,
SCRYFALL_CACHE_TTL_SECONDS,
_API_HEADERS,
log_collect,
log_cache,
)
router = APIRouter(tags=["collection"])
@router.get("/api/collection/export")
async def export_collection(
format: str = "INTERNAL_CSV",
q: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
):
"""Export collection in various formats with optional filters."""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
)
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
conn = _get_db_conn()
rows = [_row_to_dict(r) for r in conn.execute(sql, params).fetchall()]
fmt = format.upper()
output = io.StringIO()
if fmt == "INTERNAL_CSV":
if rows:
fieldnames = list(rows[0].keys())
else:
fieldnames = [
"id", "name", "type_line", "subtypes", "is_legendary", "is_basic",
"color_identity", "cmc", "oracle_text", "keywords", "tcg_price",
"salt_score", "is_game_changer", "category", "scryfall_id", "tcgplayer_id",
"quantity", "finish", "condition", "language", "notes", "tags",
"set_code", "collector_number", "created_at", "updated_at",
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
filename = "collection_export.csv"
media_type = "text/csv"
elif fmt == "MOXFIELD_CSV":
fieldnames = ["Count", "Name", "Edition", "Condition", "Language", "Foil", "Collector Number"]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
finish_val = row.get("finish", "NORMAL").upper()
foil_val = "foil" if finish_val == "FOIL" else ("etched" if finish_val == "ETCHED" else "")
writer.writerow({
"Count": row.get("quantity", 1),
"Name": row.get("name", ""),
"Edition": row.get("set_code", "").upper(),
"Condition": row.get("condition", ""),
"Language": row.get("language", ""),
"Foil": foil_val,
"Collector Number": row.get("collector_number", ""),
})
filename = "collection_moxfield.csv"
media_type = "text/csv"
elif fmt == "TEXT":
lines = []
for row in rows:
qty = row.get("quantity", 1)
name = row.get("name", "")
set_code = row.get("set_code", "").upper()
coll_num = row.get("collector_number", "")
if set_code and coll_num:
lines.append(f"{qty} {name} ({set_code}) {coll_num}")
elif set_code:
lines.append(f"{qty} {name} ({set_code})")
else:
lines.append(f"{qty} {name}")
output.write("\n".join(lines))
filename = "collection_export.txt"
media_type = "text/plain"
else:
raise HTTPException(400, f"Unknown format: {format}. Use INTERNAL_CSV, MOXFIELD_CSV, or TEXT.")
content = output.getvalue()
def iter_content():
yield content.encode("utf-8")
return StreamingResponse(
iter_content(),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/api/collection/sets")
async def collection_sets():
"""Return distinct set codes + names in the collection (for filter autocomplete)."""
conn = _get_db_conn()
rows = conn.execute(
"SELECT DISTINCT UPPER(set_code) as code, set_name as name FROM collection_entries WHERE set_code != '' ORDER BY set_name ASC"
).fetchall()
result = []
for r in rows:
code = r["code"] if "code" in r.keys() else r[0]
name = r["name"] if "name" in r.keys() else r[1]
if code:
result.append({"code": code, "name": name or code})
return JSONResponse(result)
@router.get("/api/collection/keywords")
async def collection_keywords():
"""Return distinct keywords found in the collection."""
conn = _get_db_conn()
rows = conn.execute("SELECT keywords FROM collection_entries WHERE keywords != '' AND keywords != '[]'").fetchall()
kw_set = set()
for r in rows:
try:
kws = json.loads(r["keywords"]) if isinstance(r["keywords"], str) else r["keywords"]
if isinstance(kws, list):
for k in kws:
if k:
kw_set.add(k)
except (json.JSONDecodeError, TypeError):
pass
return JSONResponse(sorted(kw_set))
@router.get("/api/collection")
async def list_collection(
page: int = 1,
pageSize: int = 50,
q: Optional[str] = None,
sortField: Optional[str] = None,
sortDir: Optional[str] = None,
colors: Optional[str] = None,
types: Optional[str] = None,
isLegendary: Optional[bool] = None,
isBasic: Optional[bool] = None,
isGameChanger: Optional[bool] = None,
highSalt: Optional[bool] = None,
finish: Optional[str] = None,
cmcMin: Optional[float] = None,
cmcMax: Optional[float] = None,
priceMin: Optional[float] = None,
priceMax: Optional[float] = None,
category: Optional[str] = None,
deck_id: Optional[int] = None,
rarity: Optional[str] = None,
setCode: Optional[str] = None,
powerMin: Optional[str] = None,
powerMax: Optional[str] = None,
toughMin: Optional[str] = None,
toughMax: Optional[str] = None,
keyword: Optional[str] = None,
edhrecMin: Optional[int] = None,
edhrecMax: Optional[int] = None,
qtyMin: Optional[int] = None,
qtyMax: Optional[int] = None,
):
"""List collection entries with search, sort, and filter.
Optional deck_id: when provided, includes in_deck_quantity for each result.
"""
where_str, params = _build_collection_filters(
q=q, colors=colors, types=types, isLegendary=isLegendary,
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
priceMin=priceMin, priceMax=priceMax, category=category,
rarity=rarity, setCode=setCode, powerMin=powerMin, powerMax=powerMax,
toughMin=toughMin, toughMax=toughMax, keyword=keyword,
edhrecMin=edhrecMin, edhrecMax=edhrecMax, qtyMin=qtyMin, qtyMax=qtyMax,
)
# Validate sort
sort_field = sortField if sortField in VALID_SORT_FIELDS else "name"
sort_dir = "DESC" if (sortDir or "").upper() == "DESC" else "ASC"
conn = _get_db_conn()
# Count total
count_sql = f"SELECT COUNT(*) FROM collection_entries {where_str}"
total = conn.execute(count_sql, params).fetchone()[0]
# Paginate
offset = (max(1, page) - 1) * pageSize
data_sql = f"""
SELECT * FROM collection_entries {where_str}
ORDER BY {sort_field} {sort_dir}
LIMIT ? OFFSET ?
"""
rows = conn.execute(data_sql, params + [pageSize, offset]).fetchall()
items = [_add_image_url(_row_to_dict(r)) for r in rows]
# If deck_id provided, annotate each item with how many copies are already in that deck
if deck_id is not None:
# Build a map of scryfall_id -> quantity in deck
deck_card_rows = conn.execute(
"SELECT scryfall_id, SUM(quantity) as qty FROM deck_cards WHERE deck_id = ? GROUP BY scryfall_id",
(deck_id,)
).fetchall()
in_deck_map = {row["scryfall_id"]: row["qty"] for row in deck_card_rows}
for item in items:
item["in_deck_quantity"] = in_deck_map.get(item.get("scryfall_id", ""), 0)
return {"items": items, "page": page, "pageSize": pageSize, "total": total}
@router.get("/api/collection/{cardId}")
async def get_collection_card(cardId: int):
"""Get a single collection entry by ID."""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
return _add_image_url(_row_to_dict(row))
@router.patch("/api/collection/{cardId}")
async def update_collection_card(cardId: int, body: dict):
"""Update mutable fields of a collection entry."""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
updates = {}
if "category" in body:
cat = body["category"]
if isinstance(cat, list):
updates["category"] = json.dumps(cat)
else:
updates["category"] = cat
if "tags" in body:
updates["tags"] = str(body["tags"])
if "notes" in body:
updates["notes"] = str(body["notes"])
if "finish" in body:
finish_val = str(body["finish"]).upper()
if finish_val not in ("NORMAL", "FOIL", "ETCHED"):
raise HTTPException(400, "finish must be NORMAL, FOIL, or ETCHED")
updates["finish"] = finish_val
if not updates:
return _add_image_url(_row_to_dict(row))
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [cardId]
conn.execute(f"UPDATE collection_entries SET {set_clause} WHERE id = ?", values)
conn.commit()
updated_row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
return _add_image_url(_row_to_dict(updated_row))
# ══════════════════════════════════════════════════════════════
# Collection Import Endpoint
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/import")
async def import_collection(body: dict):
"""
Import cards into the collection from CSV, Moxfield, Archidekt, or plain text.
Body:
{
"source": "CSV" | "MOXFIELD" | "ARCHIDEKT" | "TEXT",
"mode": "MERGE" | "REPLACE",
"content": "...csv or text content...",
"mapping": { ... optional column mapping ... }
}
"""
source = str(body.get("source", "CSV")).upper()
mode = str(body.get("mode", "MERGE")).upper()
content = str(body.get("content", ""))
mapping = body.get("mapping", {})
if not content.strip():
raise HTTPException(400, "content is required")
imported_count = 0
updated_count = 0
failed_count = 0
errors = []
conn = _get_db_conn()
# REPLACE mode: clear existing entries
if mode == "REPLACE":
conn.execute("DELETE FROM collection_entries")
conn.commit()
# Parse the content into raw rows
try:
raw_rows = _parse_csv_content(content, source, mapping if mapping else None)
except Exception as e:
raise HTTPException(400, f"Failed to parse content: {str(e)}")
for raw in raw_rows:
name = raw.get("name", "").strip()
if not name:
continue
quantity = int(raw.get("quantity", 1))
set_code = raw.get("set_code", "").lower()
collector_number = raw.get("collector_number", "")
finish = _parse_finish(raw.get("finish", ""))
condition = raw.get("condition", "")
language = raw.get("language", "")
notes = raw.get("notes", "")
tags = raw.get("tags", "")
# Scryfall enrichment
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {str(e)}")
failed_count += 1
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Card not found on Scryfall: '{name}'") if enriched else f"Card not found on Scryfall: '{name}'"
errors.append(err_msg)
failed_count += 1
continue
# Use Scryfall-resolved name if available
resolved_name = enriched.get("name", name)
# Check for existing entry (identity key)
existing = conn.execute(
"""SELECT id, quantity FROM collection_entries
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
(resolved_name, set_code, collector_number, finish),
).fetchone()
if existing and mode == "MERGE":
# Update quantity
new_qty = existing["quantity"] + quantity
conn.execute(
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
(new_qty, existing["id"]),
)
conn.commit()
updated_count += 1
else:
# Insert new row
conn.execute(
"""INSERT INTO collection_entries
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
cmc, mana_cost, oracle_text, keywords,
power, toughness, rarity, set_name, edhrec_rank,
tcg_price, salt_score, is_game_changer,
category, scryfall_id, tcgplayer_id,
quantity, finish, condition, language, notes, tags,
set_code, collector_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
resolved_name,
enriched.get("type_line", ""),
enriched.get("subtypes", "[]"),
enriched.get("is_legendary", 0),
enriched.get("is_basic", 0),
enriched.get("color_identity", "[]"),
enriched.get("cmc", 0.0),
enriched.get("mana_cost", ""),
enriched.get("oracle_text", ""),
enriched.get("keywords", "[]"),
enriched.get("power", ""),
enriched.get("toughness", ""),
enriched.get("rarity", ""),
enriched.get("set_name", ""),
enriched.get("edhrec_rank", 0),
enriched.get("tcg_price", 0.0),
enriched.get("salt_score", 0.0),
enriched.get("is_game_changer", 0),
enriched.get("category", "[]"),
enriched.get("scryfall_id", ""),
enriched.get("tcgplayer_id", ""),
quantity,
finish,
condition,
language,
notes,
tags,
set_code,
collector_number,
),
)
conn.commit()
imported_count += 1
return {
"importedCount": imported_count,
"updatedCount": updated_count,
"failedCount": failed_count,
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Scryfall Cache Management Endpoints
# ══════════════════════════════════════════════════════════════
@router.get("/api/cache/scryfall")
async def scryfall_cache_stats():
"""Return Scryfall response cache statistics."""
return _scryfall_cache.stats()
@router.delete("/api/cache/scryfall")
async def scryfall_cache_clear():
"""Clear all cached Scryfall responses."""
deleted = _scryfall_cache.clear()
return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"}
@router.post("/api/cache/scryfall/evict")
async def scryfall_cache_evict_expired():
"""Remove only expired entries (older than TTL) from the cache."""
evicted = _scryfall_cache.evict_expired()
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
File diff suppressed because it is too large. Load diff
+1561
View File
File diff suppressed because it is too large. Load diff
+800
View File
@@ -0,0 +1,800 @@
"""
routes/deepseek.py
==================
Python simulator & DeepSeek AI opponent endpoints:
POST /api/sim/run
GET /api/sim/status
GET /api/sim/result
POST /api/sim/run-from-deck
POST /api/sim/run-deepseek
POST /api/deepseek/connect
GET /api/deepseek/status
POST /api/deepseek/configure
GET /api/deepseek/logs
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
import threading as _threading
import traceback
import uuid as _uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
from fastapi.responses import JSONResponse
from routes.shared import (
CFG, log_sim,
_get_db_conn,
_get_deepseek_brain,
_ml_logging_enabled,
)
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
logger = logging.getLogger(__name__)
router = APIRouter(tags=["deepseek"])
# In-memory store for simulation runs
_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)
# ══════════════════════════════════════════════════════════════
_SRC_PATH_ADDED = False
def _ensure_src_path():
"""Add project src/ to sys.path once."""
global _SRC_PATH_ADDED
if not _SRC_PATH_ADDED:
src_dir = str(Path(__file__).resolve().parent.parent / 'src')
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
_SRC_PATH_ADDED = True
def _build_deck_from_card_data(card_data: list[dict]) -> list:
"""Build a list of Card objects from DB card dicts. Used by v2 + deepseek threads."""
_ensure_src_path()
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.rules import enrich_card
deck = []
for cd in card_data:
c = Card(name=cd['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
enrich_card(c)
deck.append(c)
return deck
def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
num_games, sim_id, **run_kwargs):
"""Run *num_games* and accumulate stats. Returns (game_results, stats, elapsed)."""
start = time.time()
wins = losses = total_turns = 0
total_damage_dealt = total_damage_received = 0
total_spells_cast = total_creatures_played = 0
total_removal_used = total_ramp_played = 0
total_cards_drawn = total_max_board = 0
game_results = []
for i in range(num_games):
kw = dict(run_kwargs)
# DeepSeek engine needs per-game IDs
if 'game_id_prefix' in kw:
prefix = kw.pop('game_id_prefix')
kw['game_id'] = f'{prefix}-g{i+1}'
result = engine.run(deck_a, deck_b, name_a=deck_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:
wins += 1
else:
losses += 1
total_turns += result.turns
if result.player_a_stats:
s = result.player_a_stats
total_damage_dealt += s.damage_dealt
total_damage_received += s.damage_received
total_spells_cast += s.spells_cast
total_creatures_played += s.creatures_played
total_removal_used += s.removal_used
total_ramp_played += s.ramp_played
total_cards_drawn += s.cards_drawn
total_max_board += s.max_board_size
with _sim_lock:
_sim_runs[sim_id]['completed'] = i + 1
elapsed = time.time() - start
stats = dict(
wins=wins, losses=losses, total_turns=total_turns,
total_damage_dealt=total_damage_dealt,
total_damage_received=total_damage_received,
total_spells_cast=total_spells_cast,
total_creatures_played=total_creatures_played,
total_removal_used=total_removal_used,
total_ramp_played=total_ramp_played,
total_cards_drawn=total_cards_drawn,
total_max_board=total_max_board,
)
return game_results, stats, elapsed
def _build_summary(stats: dict, deck_name: str, opponent_name: str,
num_games: int, elapsed: float, **extra) -> dict:
"""Build the JSON-serialisable summary dict from accumulated stats."""
n = num_games
summary = {
'deckName': deck_name,
'opponentName': opponent_name,
'totalGames': n,
'wins': stats['wins'],
'losses': stats['losses'],
'winRate': round(stats['wins'] / n * 100, 1) if n > 0 else 0.0,
'avgTurns': round(stats['total_turns'] / n, 1) if n > 0 else 0.0,
'avgDamageDealt': round(stats['total_damage_dealt'] / n, 1) if n > 0 else 0.0,
'avgDamageReceived': round(stats['total_damage_received'] / n, 1) if n > 0 else 0.0,
'avgSpellsCast': round(stats['total_spells_cast'] / n, 1) if n > 0 else 0.0,
'avgCreaturesPlayed': round(stats['total_creatures_played'] / n, 1) if n > 0 else 0.0,
'avgRemovalUsed': round(stats['total_removal_used'] / n, 1) if n > 0 else 0.0,
'avgRampPlayed': round(stats['total_ramp_played'] / n, 1) if n > 0 else 0.0,
'avgCardsDrawn': round(stats['total_cards_drawn'] / n, 1) if n > 0 else 0.0,
'avgMaxBoardSize': round(stats['total_max_board'] / n, 1) if n > 0 else 0.0,
'elapsedSeconds': round(elapsed, 3),
}
summary.update(extra)
return summary
def _finish_sim(sim_id, summary, game_results):
"""Mark a sim run as complete."""
with _sim_lock:
_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:
_sim_runs[sim_id]['status'] = 'error'
_sim_runs[sim_id]['error'] = str(error)
# ══════════════════════════════════════════════════════════════
# Background Sim Threads (now thin wrappers around shared helpers)
# ══════════════════════════════════════════════════════════════
def _run_sim_thread(sim_id: str, decklist: list, num_games: int,
deck_name: str, record_logs: bool):
"""Background thread: basic Monte Carlo sim from a raw decklist."""
try:
_ensure_src_path()
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.lab.experiments import build_deck, _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = build_deck(decklist)
deck_b = _generate_training_deck()
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',
num_games, sim_id)
summary = _build_summary(stats, deck_name, 'Training Deck',
num_games, elapsed)
_finish_sim(sim_id, summary, game_results)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
def _run_sim_thread_v2(sim_id: str, card_data: list[dict], num_games: int,
deck_name: str, record_logs: bool):
"""Background thread: sim with full card data from the DB."""
try:
_ensure_src_path()
from commander_ai_lab.sim.engine import GameEngine
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = _build_deck_from_card_data(card_data)
deck_b = _generate_training_deck()
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',
num_games, sim_id)
summary = _build_summary(stats, deck_name, 'Training Deck',
num_games, elapsed)
_finish_sim(sim_id, summary, game_results)
except Exception as e:
_fail_sim(sim_id, e)
traceback.print_exc()
def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
num_games: int, deck_name: str,
record_logs: bool):
"""Background thread: sim using DeepSeek AI opponent."""
try:
_ensure_src_path()
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.lab.experiments import _generate_training_deck
with _sim_lock:
_sim_runs[sim_id]['status'] = 'running'
deck_a = _build_deck_from_card_data(card_data)
deck_b = _generate_training_deck()
brain = _get_deepseek_brain()
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, ml_log=True,
validator=validator)
game_results, stats, elapsed = _run_game_loop(
engine, deck_a, deck_b, deck_name, 'DeepSeek AI',
num_games, sim_id,
game_id_prefix=f'ds-sim-{sim_id[:8]}',
archetype='midrange')
# Write ML decision JSONL for training pipeline
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
ml_path = os.path.join('results',
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
os.makedirs('results', exist_ok=True)
with open(ml_path, 'w', encoding='utf-8') as mf:
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
ds_stats = brain.get_stats() if brain else {}
summary = _build_summary(
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
opponentType='deepseek', deepseekStats=ds_stats)
_finish_sim(sim_id, summary, game_results)
except Exception as e:
_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
# ══════════════════════════════════════════════════════════════
def _create_sim_run(num_games, deck_name):
"""Create a queued sim run entry and return its ID."""
sim_id = str(_uuid.uuid4())[:8]
with _sim_lock:
_sim_runs[sim_id] = {
'status': 'queued', 'completed': 0,
'total': num_games, 'deckName': deck_name,
'result': None, 'error': None,
}
return sim_id
def _fetch_deck_card_data(deck_id):
"""Load card data from the DB for a given deck ID. Returns (deck_name, card_data) or raises."""
conn = _get_db_conn()
cur = conn.cursor()
cur.execute('SELECT name FROM decks WHERE id = ?', (deck_id,))
row = cur.fetchone()
if not row:
return None, None
deck_name = row[0]
cur.execute("""
SELECT dc.card_name, dc.quantity,
ce.type_line, ce.cmc, ce.power, ce.toughness,
ce.oracle_text, ce.keywords, ce.mana_cost
FROM deck_cards dc
LEFT JOIN (
SELECT scryfall_id, type_line, cmc, power, toughness,
oracle_text, keywords, mana_cost
FROM collection_entries GROUP BY scryfall_id
) ce ON ce.scryfall_id = dc.scryfall_id
WHERE dc.deck_id = ?
""", (deck_id,))
card_data = []
for r in cur.fetchall():
for _ in range(r[1] or 1):
card_data.append({
'name': r[0], 'type_line': r[2] or '',
'cmc': r[3] or 0, 'power': r[4] or '',
'toughness': r[5] or '', 'oracle_text': r[6] or '',
'keywords': r[7] or '', 'mana_cost': r[8] or '',
})
return deck_name, card_data
@router.post('/api/sim/run')
async def sim_run(request: FastAPIRequest):
"""Start a Monte Carlo simulation with the Python engine."""
body = await request.json()
decklist = body.get('decklist', [])
num_games = body.get('numGames', 10)
deck_name = body.get('deckName', 'My Deck')
record_logs = body.get('recordLogs', True)
if not decklist:
return JSONResponse({'error': 'decklist is required'}, status_code=400)
if num_games < 1 or num_games > 1000:
return JSONResponse({'error': 'numGames must be 1-1000'}, status_code=400)
sim_id = _create_sim_run(num_games, deck_name)
_threading.Thread(target=_run_sim_thread,
args=(sim_id, decklist, num_games, deck_name, record_logs),
daemon=True).start()
return JSONResponse({'simId': sim_id, 'status': 'queued', 'total': num_games})
@router.get('/api/sim/status')
async def sim_status(simId: str):
"""Poll simulation progress."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
return JSONResponse({'error': 'sim not found'}, status_code=404)
return JSONResponse({
'simId': simId, 'status': run['status'],
'completed': run['completed'], 'total': run['total'],
'deckName': run.get('deckName', ''), 'error': run.get('error'),
})
@router.get('/api/sim/result')
async def sim_result(simId: str):
"""Get completed simulation results."""
with _sim_lock:
run = _sim_runs.get(simId)
if not run:
return JSONResponse({'error': 'sim not found'}, status_code=404)
if run['status'] != 'complete':
return JSONResponse({'error': 'sim not complete', 'status': run['status']},
status_code=400)
return JSONResponse(run['result'])
@router.post('/api/sim/run-from-deck')
async def sim_run_from_deck(request: FastAPIRequest):
"""Start simulation using a deck from the Deck Builder (by deck ID)."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 10)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
deck_name, card_data = _fetch_deck_card_data(deck_id)
if deck_name is None:
return JSONResponse({'error': 'deck not found'}, status_code=404)
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = _create_sim_run(num_games, deck_name)
_threading.Thread(target=_run_sim_thread_v2,
args=(sim_id, card_data, num_games, deck_name, record_logs),
daemon=True).start()
return JSONResponse({'simId': sim_id, 'status': 'queued',
'total': num_games, 'deckName': deck_name})
@router.post('/api/sim/run-deepseek')
async def sim_run_deepseek(request: FastAPIRequest):
"""Start simulation using DeepSeek AI as the opponent brain."""
body = await request.json()
deck_id = body.get('deckId')
num_games = body.get('numGames', 5)
record_logs = body.get('recordLogs', True)
if not deck_id:
return JSONResponse({'error': 'deckId required'}, status_code=400)
if num_games < 1 or num_games > 50:
return JSONResponse({'error': 'numGames must be 1-50 for DeepSeek mode'},
status_code=400)
deck_name, card_data = _fetch_deck_card_data(deck_id)
if deck_name is None:
return JSONResponse({'error': 'deck not found'}, status_code=404)
if not card_data:
return JSONResponse({'error': 'deck has no cards'}, status_code=400)
sim_id = _create_sim_run(num_games, deck_name)
_threading.Thread(target=_run_sim_thread_deepseek,
args=(sim_id, card_data, num_games, deck_name, record_logs),
daemon=True).start()
return JSONResponse({'simId': sim_id, 'status': 'queued',
'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
# ══════════════════════════════════════════════════════════════
@router.post('/api/deepseek/connect')
async def deepseek_connect(request: FastAPIRequest):
"""Test connection to the DeepSeek LLM endpoint and auto-detect model."""
body = await request.json() if await request.body() else {}
api_base = body.get('apiBase', None)
model = body.get('model', None)
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'DeepSeek brain failed to initialize'},
status_code=500)
if api_base:
brain.config.api_base = api_base
if model:
brain.config.model = model
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'apiBase': brain.config.api_base,
'model': brain.config.model,
'stats': brain.get_stats(),
})
@router.get('/api/deepseek/status')
async def deepseek_status():
"""Get DeepSeek brain status and performance stats."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'connected': False, 'error': 'Brain not initialized'})
return JSONResponse(brain.get_stats())
@router.post('/api/deepseek/configure')
async def deepseek_configure(request: FastAPIRequest):
"""Update DeepSeek configuration at runtime."""
body = await request.json()
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
if 'apiBase' in body:
brain.config.api_base = body['apiBase']
if 'model' in body:
brain.config.model = body['model']
if 'temperature' in body:
brain.config.temperature = float(body['temperature'])
if 'maxTokens' in body:
brain.config.max_tokens = int(body['maxTokens'])
if 'timeout' in body:
brain.config.request_timeout = float(body['timeout'])
if 'cacheEnabled' in body:
brain.config.cache_enabled = bool(body['cacheEnabled'])
if 'logDecisions' in body:
brain.config.log_decisions = bool(body['logDecisions'])
if 'fallbackOnTimeout' in body:
brain.config.fallback_on_timeout = bool(body['fallbackOnTimeout'])
connected = brain.check_connection()
return JSONResponse({
'connected': connected,
'config': {
'apiBase': brain.config.api_base,
'model': brain.config.model,
'temperature': brain.config.temperature,
'maxTokens': brain.config.max_tokens,
'timeout': brain.config.request_timeout,
'cacheEnabled': brain.config.cache_enabled,
'logDecisions': brain.config.log_decisions,
'fallbackOnTimeout': brain.config.fallback_on_timeout,
},
})
@router.get('/api/deepseek/logs')
async def deepseek_logs():
"""Get decision log stats and flush pending entries."""
brain = _get_deepseek_brain()
if brain is None:
return JSONResponse({'error': 'Brain not initialized'}, status_code=500)
pending = len(brain._decision_log)
flushed_path = None
if pending > 0:
try:
flushed_path = brain.flush_log()
except Exception as e:
return JSONResponse({'error': f'Failed to flush: {e}'},
status_code=500)
import glob as _glob
log_dir = brain.config.log_dir
log_files = []
if log_dir and os.path.isdir(log_dir):
for f in sorted(_glob.glob(os.path.join(log_dir, 'decisions_*.jsonl'))):
stat = os.stat(f)
log_files.append({
'filename': os.path.basename(f),
'size_bytes': stat.st_size,
'modified': stat.st_mtime,
})
return JSONResponse({
'flushed': pending,
'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())
+127
View File
@@ -0,0 +1,127 @@
"""
routes/import_routes.py
=======================
Deck import & commander meta endpoints:
POST /api/lab/import/url
POST /api/lab/import/text
GET /api/lab/meta/commanders
GET /api/lab/meta/search
POST /api/lab/meta/fetch
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import routes.shared as _shared
from routes.shared import (
ImportUrlRequest,
ImportTextRequest,
MetaFetchRequest,
_import_from_url,
_parse_text_decklist,
_save_profile_to_dck,
_fetch_edhrec_average,
log,
)
router = APIRouter(tags=["import"])
@router.post("/api/lab/import/url")
async def import_from_url(req: ImportUrlRequest):
"""Import a deck from Archidekt or EDHREC URL. Returns parsed DeckProfile + saves .dck file."""
url = req.url.strip()
try:
profile = _import_from_url(url)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": profile["source"],
"sourceUrl": profile["sourceUrl"],
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
"colorIdentity": profile.get("colorIdentity", []),
"archetype": profile.get("archetype"),
}
except Exception as e:
raise HTTPException(400, str(e))
@router.post("/api/lab/import/text")
async def import_from_text(req: ImportTextRequest):
"""Import a deck from plain text card list."""
try:
profile = _parse_text_decklist(req.text, req.commander)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": "Text Import",
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
}
except Exception as e:
raise HTTPException(400, str(e))
@router.get("/api/lab/meta/commanders")
async def list_meta_commanders():
"""List available commanders in the meta mapping."""
commanders = []
for name, entries in _shared.COMMANDER_META.items():
entry = entries[0] if entries else {}
commanders.append({
"name": name,
"archetype": entry.get("archetype", ""),
"colorIdentity": entry.get("colorIdentity", []),
"source": entry.get("source", "edhrec"),
})
return {"commanders": commanders}
@router.get("/api/lab/meta/search")
async def search_meta_commanders(q: str = ""):
"""Search commanders by partial name match."""
query = q.lower()
matches = []
for name, entries in _shared.COMMANDER_META.items():
if query in name.lower():
entry = entries[0] if entries else {}
matches.append({
"name": name,
"archetype": entry.get("archetype", ""),
"colorIdentity": entry.get("colorIdentity", []),
})
return {"results": matches}
@router.post("/api/lab/meta/fetch")
async def fetch_meta_deck(req: MetaFetchRequest):
"""Fetch EDHREC average deck for a commander and save as .dck file."""
try:
profile = _fetch_edhrec_average(req.commander)
dck_path = _save_profile_to_dck(profile)
return {
"success": True,
"deckName": profile["name"],
"commander": profile["commander"],
"source": "EDHREC Average",
"sourceUrl": profile.get("sourceUrl", ""),
"totalCards": profile["totalCards"],
"dckFile": dck_path.stem,
"colorIdentity": profile.get("colorIdentity", []),
"sampleSize": profile.get("sampleSize"),
}
except Exception as e:
raise HTTPException(400, f"Failed to fetch meta deck for '{req.commander}': {str(e)}")
# ══════════════════════════════════════════════════════════════
# Existing Endpoints (v1/v2)
# ══════════════════════════════════════════════════════════════
+461
View File
@@ -0,0 +1,461 @@
"""
routes/lab.py
=============
Batch simulation & lab management endpoints:
POST /api/lab/start
POST /api/lab/start-deepseek
GET /api/lab/status
GET /api/lab/result
GET /api/lab/decks
GET /api/lab/history
GET /api/lab/profiles
GET /api/lab/profiles/{name}
GET /api/lab/analytics/{deck_name}
GET /api/lab/trends/{deck_name}
GET /api/lab/log
GET /api/lab/debug-log
"""
from __future__ import annotations
import glob
import json
import math
import os
import re
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.responses import JSONResponse
from routes.shared import (
CFG,
active_batches,
BatchState,
StartRequest,
StartResponse,
StatusResponse,
AI_PROFILES,
build_java_command,
run_batch_subprocess,
_run_deepseek_batch_thread,
parse_dck_file,
_get_db_conn,
_load_deck_cards_by_name,
_ml_logging_enabled,
log,
log_batch,
)
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."""
if len(req.decks) < 2 or len(req.decks) > 4:
raise HTTPException(400, "2-4 decks required")
if not all(req.decks):
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
if req.numGames < 1 or req.numGames > 10000:
raise HTTPException(400, "numGames must be 1-10000")
if req.threads < 1 or req.threads > 16:
raise HTTPException(400, "threads must be 1-16")
if not os.path.exists(CFG.forge_jar):
raise HTTPException(500, f"Forge JAR not found: {CFG.forge_jar}")
if not os.path.isdir(CFG.forge_dir):
raise HTTPException(500, f"Forge dir not found: {CFG.forge_dir}")
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, req.numGames, req.threads)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
state.result_path = output_path
background_tasks.add_task(
run_batch_subprocess,
state,
req.decks,
req.numGames,
req.threads,
req.seed,
req.clock,
output_path,
req.useLearnedPolicy,
req.policyStyle,
req.policyGreedy,
req.aiSimplified,
req.aiThinkTimeMs,
req.maxQueueDepth,
)
policy_msg = ""
if req.useLearnedPolicy:
policy_msg = f" [Learned Policy: {req.policyStyle}]"
return StartResponse(
batchId=batch_id,
status="started",
message=f"Running {req.numGames} games with {req.threads} threads{policy_msg}",
)
@router.get("/api/lab/status", response_model=StatusResponse)
async def get_status(batchId: Optional[str] = None):
# If no batchId given, find the most recent active (or last) batch
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Return the most recently active batch, or any batch
for s in active_batches.values():
if s.running:
state = s
break
if state is None and active_batches:
state = list(active_batches.values())[-1]
if not state:
return StatusResponse(
batchId="", running=False, completed=0, total=0,
threads=0, elapsedMs=0, error=None, simsPerSec=0.0,
run_id="", games_completed=0, total_games=0, current_decks=[],
)
elapsed = int((datetime.now() - state.start_time).total_seconds() * 1000)
deck_names = getattr(state, 'deck_names', []) if hasattr(state, 'deck_names') else []
return StatusResponse(
batchId=state.batch_id,
running=state.running,
completed=state.completed_games,
total=state.total_games,
threads=state.threads,
elapsedMs=elapsed if state.running else state.elapsed_ms,
error=state.error,
simsPerSec=state.sims_per_sec,
# React SPA fields
run_id=state.batch_id,
games_completed=state.completed_games,
total_games=state.total_games,
current_decks=deck_names,
)
@router.get("/api/lab/result")
async def get_result(batchId: Optional[str] = None):
state = None
if batchId:
state = active_batches.get(batchId)
else:
# Find most recent completed batch
for s in reversed(list(active_batches.values())):
if not s.running and s.result_path:
state = s
break
if not state:
raise HTTPException(404, "No batch result available")
if state.running:
raise HTTPException(409, "Batch still running")
if state.error:
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")
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)
@router.get("/api/lab/decks")
async def list_decks():
decks = []
seen_names = set()
# 1. Forge .dck files from decks directory
decks_dir = CFG.forge_decks_dir
if decks_dir and os.path.isdir(decks_dir):
for f in sorted(Path(decks_dir).glob("*.dck")):
deck_name = f.stem
decks.append({"name": deck_name, "filename": f.name, "source": "forge"})
seen_names.add(deck_name.lower())
# 2. Decks from the Deck Builder database
try:
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, commander_name FROM decks ORDER BY id DESC"
).fetchall()
for row in rows:
db_name = row["name"]
if db_name.lower() not in seen_names:
decks.append({
"name": db_name,
"filename": "",
"source": "deckbuilder",
"deck_id": row["id"],
"commander": row["commander_name"] or "",
})
seen_names.add(db_name.lower())
except Exception as e:
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
return {"decks": decks}
@router.get("/api/lab/history")
async def list_history():
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"results": []}
results = []
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
meta = data.get("metadata", {})
decks = data.get("decks", [])
results.append({
"filename": f.name,
"batchId": meta.get("batchId", ""),
"timestamp": meta.get("timestamp", ""),
"totalGames": meta.get("completedGames", 0),
"threads": meta.get("threads", 1),
"elapsedMs": meta.get("elapsedMs", 0),
"decks": [{"name": d.get("deckName", ""), "source": d.get("source", "")} for d in decks],
})
except Exception:
continue
return {"results": results[:50]}
# ════════════════════════════════════════════════════════════
# AI Profiles
# ════════════════════════════════════════════════════════════
AI_PROFILES = {
"default": {
"name": "default",
"description": "Balanced \u2014 Forge's default AI behavior",
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
},
"aggro": {
"name": "aggro",
"description": "Aggressive \u2014 attacks early, prioritizes damage",
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
},
"control": {
"name": "control",
"description": "Control \u2014 defensive, removal-heavy, card advantage",
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
},
"combo": {
"name": "combo",
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
},
"midrange": {
"name": "midrange",
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
},
}
@router.get("/api/lab/profiles")
async def list_profiles():
return {"profiles": list(AI_PROFILES.values())}
@router.get("/api/lab/profiles/{name}")
async def get_profile(name: str):
profile = AI_PROFILES.get(name.lower())
if not profile:
raise HTTPException(404, f"Profile '{name}' not found. Available: {list(AI_PROFILES.keys())}")
return profile
@router.get("/api/lab/analytics/{deck_name}")
async def analyze_deck(deck_name: str):
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Decks directory not found: {decks_dir}")
deck_path = os.path.join(decks_dir, deck_name + ".dck")
if not os.path.exists(deck_path):
for f in os.listdir(decks_dir):
if f.lower() == deck_name.lower() + ".dck":
deck_path = os.path.join(decks_dir, f)
break
else:
raise HTTPException(404, f"Deck not found: {deck_name}")
try:
analysis = parse_dck_file(deck_path)
return analysis
except Exception as e:
raise HTTPException(500, f"Analysis failed: {str(e)}")
def parse_dck_file(deck_path: str) -> dict:
cards = []
commander = None
section = "Main"
deck_name = Path(deck_path).stem
with open(deck_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("//"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1]
continue
if line.startswith("Name="):
deck_name = line[5:].strip()
continue
m = re.match(r"^(\d+)\s+(.+?)(?:\|(.+))?$", line)
if m:
qty = int(m.group(1))
name = m.group(2).strip()
set_code = m.group(3).strip() if m.group(3) else ""
cards.append({
"quantity": qty, "name": name, "set": set_code,
"section": section,
"is_commander": 1 if section == "Commander" else 0,
})
if section == "Commander":
commander = name
total = sum(c["quantity"] for c in cards)
return {
"deckName": deck_name,
"commanderName": commander,
"totalCards": total,
"cardCount": len(cards),
"cards": cards[:200],
}
@router.get("/api/lab/trends/{deck_name}")
async def get_deck_trends(deck_name: str):
results_dir = Path(CFG.results_dir)
if not results_dir.exists():
return {"deckName": deck_name, "history": []}
history = []
for f in sorted(results_dir.glob("batch-*.json")):
try:
with open(f, "r", encoding="utf-8") as fh:
data = json.load(fh)
summary = data.get("summary", {})
meta = data.get("metadata", {})
per_deck = summary.get("perDeck", [])
for ds in per_deck:
if ds.get("deckName", "").lower() == deck_name.lower():
history.append({
"batchId": meta.get("batchId"),
"timestamp": meta.get("timestamp"),
"winRate": ds.get("winRate", 0),
"wins": ds.get("wins", 0),
"losses": ds.get("losses", 0),
"draws": ds.get("draws", 0),
"totalGames": meta.get("completedGames", 0),
})
break
except Exception:
continue
return {"deckName": deck_name, "history": history}
@router.get("/api/lab/log")
async def get_log(batchId: str):
state = active_batches.get(batchId)
if not state:
raise HTTPException(404, f"Batch {batchId} not found")
return {"lines": state.log_lines[-200:]}
@router.get("/api/lab/debug-log")
async def get_debug_log():
"""Return the raw Forge subprocess debug log for diagnosing simulation issues."""
# Check multiple possible locations for the debug log
candidates = []
if CFG.forge_dir:
candidates.append(Path(CFG.forge_dir).parent / "forge-sim-debug.log")
candidates.append(Path(CFG.forge_dir) / "forge-sim-debug.log")
candidates.append(Path("forge-sim-debug.log"))
for log_path in candidates:
if log_path.exists():
text = log_path.read_text(encoding="utf-8", errors="replace")
# Return last 50KB to avoid huge responses
if len(text) > 50000:
text = "... (truncated) ...\n" + text[-50000:]
return {"path": str(log_path), "content": text}
return {"path": None, "content": "No debug log found. Run a simulation first."}
@router.post('/api/lab/start-deepseek')
async def start_batch_deepseek(request: FastAPIRequest, background_tasks: BackgroundTasks):
"""Start a batch simulation using the Python sim engine + DeepSeek LLM opponent."""
body = await request.json()
decks = body.get('decks', [])
num_games = body.get('numGames', 30)
# Note: threads not applicable for Python sim (single-threaded LLM calls)
if not decks or len(decks) < 1:
raise HTTPException(400, 'At least 1 deck required')
if num_games < 1 or num_games > 500:
raise HTTPException(400, 'numGames must be 1-500')
# Filter empty deck slots
decks = [d for d in decks if d]
if not decks:
raise HTTPException(400, 'No valid decks provided')
batch_id = str(uuid.uuid4())[:12]
state = BatchState(batch_id, num_games, 1)
active_batches[batch_id] = state
os.makedirs(CFG.results_dir, exist_ok=True)
output_path = os.path.join(CFG.results_dir, f'batch-{batch_id}.json')
state.result_path = output_path
# Use threading instead of BackgroundTasks to avoid async issues with DB
t = threading.Thread(
target=_run_deepseek_batch_thread,
args=(state, decks, num_games, output_path),
daemon=True,
)
t.start()
return JSONResponse({
'batchId': batch_id,
'status': 'started',
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
'engine': 'deepseek',
})
# Global ML logging toggle (can be enabled via API)
_ml_logging_enabled = True
+729
View File
@@ -0,0 +1,729 @@
"""
routes/ml.py
============
ML training data, policy inference, supervised training,
PPO training, and tournament endpoints.
GET /api/ml/status
GET /api/ml/decisions/{filename}
GET /api/ml/data/status
POST /api/ml/toggle
POST /api/ml/predict
POST /api/ml/predict/batch
GET /api/ml/model
POST /api/ml/reload
POST /api/ml/train
GET /api/ml/train/status
POST /api/ml/train/ppo
GET /api/ml/train/ppo/status
POST /api/ml/tournament
GET /api/ml/tournament/status
GET /api/ml/tournament/results
"""
from __future__ import annotations
import dataclasses
import json
import logging
import os
import threading
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
from routes.shared import CFG, _ml_logging_enabled
log_ml = logging.getLogger("commander_ai_lab.ml")
router = APIRouter(tags=["ml"])
# ── module-level singletons ──────────────────────────────────────────────────
_policy_service = None
_policy_service_init_attempted = False
# ── Thread-safe state objects ────────────────────────────────────────────────
@dataclasses.dataclass
class _TrainingState:
running: bool = False
progress: int = 0
total_epochs: int = 0
current_epoch: int = 0
phase: str = "idle"
message: str = ""
metrics: object = None
result: object = None
error: object = None
started_at: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
@dataclasses.dataclass
class _PPOState:
running: bool = False
iteration: int = 0
total_iterations: int = 0
phase: str = "idle"
message: str = ""
metrics: object = None
result: object = None
error: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
@dataclasses.dataclass
class _TournamentState:
running: bool = False
phase: str = "idle"
message: str = ""
result: object = None
error: object = None
def snapshot(self) -> dict:
return dataclasses.asdict(self)
_training_state = _TrainingState()
_training_lock = threading.Lock()
_ppo_state = _PPOState()
_ppo_lock = threading.Lock()
_tournament_state = _TournamentState()
_tournament_lock = threading.Lock()
# module-level toggle (local copy; the shared one is for batch runs)
_ml_logging_enabled_local = _ml_logging_enabled
def _get_policy_service():
"""Get or initialize the policy inference service (lazy loading)."""
global _policy_service, _policy_service_init_attempted
if _policy_service is None and not _policy_service_init_attempted:
_policy_service_init_attempted = True
try:
from ml.serving.policy_server import PolicyInferenceService
_policy_service = PolicyInferenceService()
if _policy_service.load():
log_ml.info(f"Policy model loaded on {_policy_service.device}")
else:
log_ml.error(f"Policy model not available: {_policy_service._load_error}")
except Exception as e:
log_ml.error(f"Policy service init failed: {e}")
return _policy_service
# ==============================================================
# ML Training Data Endpoints
# ==============================================================
@router.get("/api/ml/status")
async def ml_status():
"""Get ML decision logging status and available training data."""
import routes.shared as _shared
lab_root = Path(__file__).resolve().parent.parent
results_dir = lab_root / CFG.results_dir
ml_files = []
if results_dir.exists():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
lines = 0
try:
with open(f) as fh:
lines = sum(1 for _ in fh)
except Exception:
pass
ml_files.append({
"file": f.name,
"decisions": lines,
"size_kb": round(f.stat().st_size / 1024, 1),
})
total_decisions = sum(f["decisions"] for f in ml_files)
return {
"ml_logging_enabled": _shared._ml_logging_enabled,
"training_files": ml_files,
"total_decisions": total_decisions,
"total_files": len(ml_files),
}
@router.post("/api/ml/toggle")
async def ml_toggle(enable: bool = True):
"""Enable or disable ML decision logging for future batch runs."""
import routes.shared as _shared
_shared._ml_logging_enabled = enable
return {
"ml_logging_enabled": _shared._ml_logging_enabled,
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
}
@router.get("/api/ml/decisions/{filename}")
async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
"""Read decision snapshots from a training data file."""
lab_root = Path(__file__).resolve().parent.parent
filepath = lab_root / CFG.results_dir / filename
if not filepath.exists() or not filename.startswith("ml-decisions-"):
raise HTTPException(404, f"ML decisions file not found: {filename}")
decisions = []
try:
with open(filepath) as f:
for i, line in enumerate(f):
if i < offset:
continue
if len(decisions) >= limit:
break
try:
decisions.append(json.loads(line.strip()))
except json.JSONDecodeError:
continue
except Exception as e:
raise HTTPException(500, f"Failed to read decisions: {e}")
return {
"file": filename,
"offset": offset,
"limit": limit,
"count": len(decisions),
"decisions": decisions,
}
# ==============================================================
# ML Policy Inference Endpoints
# ==============================================================
@router.post("/api/ml/predict")
async def ml_predict(request: FastAPIRequest):
"""Predict a macro-action from a game state snapshot."""
svc = _get_policy_service()
if svc is None or not svc._loaded:
detail = "Policy model not loaded. "
if svc:
detail += svc._load_error or "Train a model first."
else:
detail += "PyTorch may not be installed."
raise HTTPException(status_code=503, detail=detail)
body = await request.json()
playstyle = body.pop("archetype", "midrange")
temperature = body.pop("temperature", 1.0)
greedy = body.pop("greedy", False)
result = svc.predict(
decision_snapshot=body,
playstyle=playstyle,
temperature=temperature,
greedy=greedy,
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@router.post("/api/ml/predict/batch")
async def ml_predict_batch(request: FastAPIRequest):
"""Predict actions for multiple snapshots at once."""
svc = _get_policy_service()
if svc is None or not svc._loaded:
raise HTTPException(status_code=503, detail="Policy model not loaded")
body = await request.json()
snapshots = body.get("snapshots", [])
greedy = body.get("greedy", True)
if not snapshots:
return {"results": []}
results = svc.predict_batch(snapshots, greedy=greedy)
return {"results": results, "count": len(results)}
@router.get("/api/ml/model")
async def ml_model_info():
"""Get information about the loaded policy model."""
svc = _get_policy_service()
if svc is None:
return {
"loaded": False,
"error": "Policy service not initialized",
"torch_available": False,
}
return svc.get_status()
@router.post("/api/ml/reload")
async def ml_reload_model(checkpoint: str = None):
"""Hot-reload a policy model checkpoint."""
svc = _get_policy_service()
if svc is None:
raise HTTPException(status_code=503, detail="Policy service not available")
if not svc._loaded:
ok = svc.load()
else:
ok = svc.reload(checkpoint)
return {
"success": ok,
"status": svc.get_status(),
}
# ==============================================================
# ML Training Management Endpoints
# ==============================================================
def _run_training_pipeline(
results_dir: str, epochs: int, lr: float,
batch_size: int, patience: int, rebuild_dataset: bool,
):
"""Run the full ML training pipeline in a background thread."""
global _policy_service, _policy_service_init_attempted
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _training_lock:
_training_state.running = True
_training_state.started_at = datetime.now().isoformat()
_training_state.error = None
_training_state.result = None
data_dir = os.path.join(project_root, "ml", "models")
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
os.makedirs(data_dir, exist_ok=True)
os.makedirs(ckpt_dir, exist_ok=True)
# Phase 1: Build Dataset
train_path = os.path.join(data_dir, "train.npz")
if rebuild_dataset or not os.path.exists(train_path):
with _training_lock:
_training_state.phase = "building"
_training_state.message = "Loading card embeddings & building dataset..."
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
dataset = build_dataset(results_dir=results_dir)
if not dataset:
raise RuntimeError("No training data produced. Check server log for details.")
train_ds, val_ds, test_ds = split_dataset(dataset)
save_dataset(train_ds, os.path.join(data_dir, "train.npz"))
save_dataset(val_ds, os.path.join(data_dir, "val.npz"))
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
total_samples = len(dataset["states"])
with _training_lock:
_training_state.message = f"Dataset built: {total_samples} samples"
log_ml.info(f"Dataset built: {total_samples} samples")
# Phase 2: Train
with _training_lock:
_training_state.phase = "training"
_training_state.total_epochs = epochs
_training_state.current_epoch = 0
_training_state.message = f"Training policy network ({epochs} epochs)..."
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
import numpy as np
import torch
from ml.training.policy_network import PolicyNetwork
from ml.training.trainer import SupervisedTrainer
def load_npz_split(path):
data = np.load(path)
return data["states"].astype(np.float32), data["labels"].astype(np.int64)
train_states, train_labels = load_npz_split(train_path)
val_path = os.path.join(data_dir, "val.npz")
val_states, val_labels = load_npz_split(val_path)
actual_dim = train_states.shape[1]
model = PolicyNetwork(input_dim=actual_dim)
device = "cpu"
if torch.cuda.is_available():
device = "cuda"
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
device = "mps"
trainer = SupervisedTrainer(
model=model, device=device, learning_rate=lr,
batch_size=batch_size, epochs=epochs, patience=patience,
checkpoint_dir=ckpt_dir,
)
summary = trainer.train(train_states, train_labels, val_states, val_labels)
with _training_lock:
_training_state.current_epoch = summary.get("epochs_trained", epochs)
_training_state.metrics = summary
# Phase 3: Evaluate
with _training_lock:
_training_state.phase = "evaluating"
_training_state.message = "Evaluating on test set..."
test_path = os.path.join(data_dir, "test.npz")
eval_results = None
if os.path.exists(test_path):
from ml.training.trainer import load_checkpoint, evaluate_model
test_states, test_labels = load_npz_split(test_path)
best_model, _ = load_checkpoint(summary["checkpoint_path"], device)
eval_results = evaluate_model(best_model, test_states, test_labels, device)
eval_path = os.path.join(ckpt_dir, "eval_results.json")
with open(eval_path, "w") as f:
json.dump(eval_results, f, indent=2)
# Done
with _training_lock:
_training_state.phase = "done"
_training_state.running = False
_training_state.result = {
"training": summary,
"evaluation": eval_results,
"checkpoint": summary.get("checkpoint_path", ""),
"device": device,
}
_training_state.message = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
# Auto-reload policy server
if _policy_service and _policy_service._loaded:
_policy_service.reload(summary.get("checkpoint_path"))
log_ml.info("Policy server reloaded with new checkpoint")
elif not _policy_service_init_attempted:
_policy_service_init_attempted = False
except Exception as e:
import traceback
with _training_lock:
_training_state.phase = "error"
_training_state.running = False
_training_state.error = str(e)
_training_state.message = f"Training failed: {e}"
log_ml.error(f"ERROR: {e}")
traceback.print_exc()
@router.post("/api/ml/train")
async def ml_start_training(request: FastAPIRequest):
"""Trigger ML training pipeline from the web UI."""
if _training_state.running:
raise HTTPException(409, "Training already in progress")
body = await request.json() if await request.body() else {}
epochs = body.get("epochs", 50)
lr = body.get("lr", 0.001)
batch_size = body.get("batchSize", 256)
patience = body.get("patience", 10)
rebuild = body.get("rebuildDataset", True)
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
with _training_lock:
_training_state.running = True
_training_state.progress = 0
_training_state.total_epochs = epochs
_training_state.current_epoch = 0
_training_state.phase = "starting"
_training_state.message = "Initializing training pipeline..."
_training_state.metrics = None
_training_state.result = None
_training_state.error = None
thread = threading.Thread(
target=_run_training_pipeline,
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
daemon=True,
)
thread.start()
return {
"status": "started",
"config": {
"epochs": epochs, "lr": lr, "batchSize": batch_size,
"patience": patience, "rebuildDataset": rebuild,
},
}
@router.get("/api/ml/train")
async def ml_training_status_poll():
"""Return current training status (polled by Unity client)."""
with _training_lock:
return _training_state.snapshot()
@router.get("/api/ml/train/status")
async def ml_training_status():
"""Get current training pipeline status."""
with _training_lock:
return _training_state.snapshot()
@router.get("/api/ml/data/status")
async def ml_data_status():
"""Get status of available training data and model checkpoints."""
project_root = Path(__file__).resolve().parent.parent
results_dir = project_root / "results"
data_dir = project_root / "ml" / "models"
ckpt_dir = data_dir / "checkpoints"
decision_files = []
if results_dir.exists():
for f in sorted(results_dir.glob("ml-decisions-*.jsonl")):
line_count = 0
try:
with open(f, "r") as fh:
line_count = sum(1 for _ in fh)
except Exception:
pass
decision_files.append({"name": f.name, "size": f.stat().st_size, "decisions": line_count})
datasets = {}
for split in ["train", "val", "test"]:
path = data_dir / f"{split}.npz"
if path.exists():
try:
import numpy as np
data = np.load(str(path))
datasets[split] = {
"samples": int(data["states"].shape[0]),
"features": int(data["states"].shape[1]),
"size": path.stat().st_size,
}
except Exception:
datasets[split] = {"size": path.stat().st_size}
checkpoints = []
if ckpt_dir.exists():
for f in sorted(ckpt_dir.glob("*.pt")):
checkpoints.append({
"name": f.name,
"size": f.stat().st_size,
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
eval_path = ckpt_dir / "eval_results.json"
eval_results = None
if eval_path.exists():
try:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
return {
"decisionFiles": decision_files,
"totalDecisions": sum(d["decisions"] for d in decision_files),
"datasets": datasets,
"checkpoints": checkpoints,
"evalResults": eval_results,
"policyLoaded": _policy_service is not None and _policy_service._loaded if _policy_service else False,
}
# ==============================================================
# PPO Training + Tournament Endpoints
# ==============================================================
def _run_ppo_pipeline(
iterations: int, episodes_per_iter: int, ppo_epochs: int,
batch_size: int, lr: float, clip_epsilon: float,
entropy_coeff: float, opponent: str, playstyle: str,
load_supervised: str,
):
"""Run PPO training in a background thread."""
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _ppo_lock:
_ppo_state.running = True
_ppo_state.error = None
_ppo_state.result = None
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
config = PPOConfig(
iterations=iterations, episodes_per_iter=episodes_per_iter,
ppo_epochs=ppo_epochs, batch_size=batch_size,
learning_rate=lr, clip_epsilon=clip_epsilon,
entropy_coeff=entropy_coeff, opponent=opponent,
playstyle=playstyle, checkpoint_dir=ckpt_dir,
load_supervised=load_supervised if load_supervised else None,
)
trainer = PPOTrainer(config)
def progress_cb(iteration, metrics):
with _ppo_lock:
_ppo_state.iteration = iteration
_ppo_state.phase = "training"
_ppo_state.message = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
_ppo_state.metrics = metrics
summary = trainer.train(progress_callback=progress_cb)
with _ppo_lock:
_ppo_state.phase = "done"
_ppo_state.running = False
_ppo_state.result = summary
_ppo_state.message = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
except Exception as e:
import traceback
with _ppo_lock:
_ppo_state.phase = "error"
_ppo_state.running = False
_ppo_state.error = str(e)
_ppo_state.message = f"PPO failed: {e}"
traceback.print_exc()
@router.post("/api/ml/train/ppo")
async def ml_start_ppo(request: FastAPIRequest):
"""Start PPO training pipeline."""
if _ppo_state.running:
raise HTTPException(409, "PPO training already in progress")
if _training_state.running:
raise HTTPException(409, "Supervised training in progress -- wait for it to finish")
body = await request.json() if await request.body() else {}
iterations = body.get("iterations", 100)
episodes = body.get("episodesPerIter", 64)
ppo_epochs = body.get("ppoEpochs", 4)
batch_size = body.get("batchSize", 256)
lr = body.get("lr", 3e-4)
clip_eps = body.get("clipEpsilon", 0.2)
entropy = body.get("entropyCoeff", 0.01)
opponent = body.get("opponent", "heuristic")
playstyle = body.get("playstyle", "midrange")
load_sup = body.get("loadSupervised", "")
with _ppo_lock:
_ppo_state.running = True
_ppo_state.iteration = 0
_ppo_state.total_iterations = iterations
_ppo_state.phase = "starting"
_ppo_state.message = "Initializing PPO..."
_ppo_state.metrics = None
_ppo_state.result = None
_ppo_state.error = None
thread = threading.Thread(
target=_run_ppo_pipeline,
args=(iterations, episodes, ppo_epochs, batch_size, lr,
clip_eps, entropy, opponent, playstyle, load_sup),
daemon=True,
)
thread.start()
return {"status": "started", "iterations": iterations}
@router.get("/api/ml/train/ppo/status")
async def ml_ppo_status():
"""Get PPO training status."""
with _ppo_lock:
return _ppo_state.snapshot()
def _run_tournament_pipeline(episodes: int, playstyle: str):
"""Run tournament in a background thread."""
try:
import sys
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
with _tournament_lock:
_tournament_state.running = True
_tournament_state.error = None
_tournament_state.result = None
_tournament_state.phase = "running"
_tournament_state.message = "Running tournament..."
from ml.eval.tournament import (
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
)
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
policies = {
"heuristic": HeuristicPolicy(),
"random": RandomPolicy(),
}
sup_path = os.path.join(ckpt_dir, "best_policy.pt")
if os.path.exists(sup_path):
policies["supervised"] = LearnedPolicy(sup_path)
ppo_path = os.path.join(ckpt_dir, "best_ppo.pt")
if os.path.exists(ppo_path):
policies["ppo"] = LearnedPolicy(ppo_path)
result = run_tournament(
policies=policies,
episodes_per_matchup=episodes,
playstyle=playstyle,
)
output_path = os.path.join(ckpt_dir, "tournament_results.json")
with open(output_path, "w") as f:
json.dump(result.to_dict(), f, indent=2)
with _tournament_lock:
_tournament_state.phase = "done"
_tournament_state.running = False
_tournament_state.result = result.to_dict()
_tournament_state.message = f"Tournament complete! {result.total_matches} matches"
except Exception as e:
import traceback
with _tournament_lock:
_tournament_state.phase = "error"
_tournament_state.running = False
_tournament_state.error = str(e)
_tournament_state.message = f"Tournament failed: {e}"
traceback.print_exc()
@router.post("/api/ml/tournament")
async def ml_start_tournament(request: FastAPIRequest):
"""Start a tournament evaluation."""
if _tournament_state.running:
raise HTTPException(409, "Tournament already in progress")
body = await request.json() if await request.body() else {}
episodes = body.get("episodes", 50)
playstyle = body.get("playstyle", "midrange")
with _tournament_lock:
_tournament_state.running = True
_tournament_state.phase = "starting"
_tournament_state.message = "Initializing tournament..."
_tournament_state.result = None
_tournament_state.error = None
thread = threading.Thread(
target=_run_tournament_pipeline,
args=(episodes, playstyle),
daemon=True,
)
thread.start()
return {"status": "started", "episodes": episodes}
@router.get("/api/ml/tournament/status")
async def ml_tournament_status():
"""Get tournament status."""
with _tournament_lock:
return _tournament_state.snapshot()
@router.get("/api/ml/tournament/results")
async def ml_tournament_results():
"""Get latest tournament results."""
project_root = Path(__file__).resolve().parent.parent
results_path = project_root / "ml" / "models" / "checkpoints" / "tournament_results.json"
if results_path.exists():
with open(results_path, "r") as f:
return json.load(f)
return {"error": "No tournament results found. Run a tournament first."}
# ==============================================================
# Model listing and stats endpoints
# ==============================================================
@router.get("/api/ml/models")
async def ml_list_models():
"""List available policy model checkpoints."""
project_root = Path(__file__).resolve().parent.parent
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
checkpoints = []
if ckpt_dir.exists():
for f in sorted(ckpt_dir.glob("*.pt")):
checkpoints.append({
"name": f.name,
"path": str(f),
"size_kb": round(f.stat().st_size / 1024, 1),
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
svc = _get_policy_service()
return {
"models": checkpoints,
"count": len(checkpoints),
"active_model": svc.get_status() if svc else None,
}
@router.get("/api/ml/stats")
async def ml_stats():
"""Return aggregate ML training statistics and model performance metrics."""
project_root = Path(__file__).resolve().parent.parent
ckpt_dir = project_root / "ml" / "models" / "checkpoints"
eval_results = None
eval_path = ckpt_dir / "eval_results.json"
if eval_path.exists():
try:
with open(eval_path, "r") as f:
eval_results = json.load(f)
except Exception:
pass
with _training_lock:
training_snapshot = _training_state.snapshot()
with _ppo_lock:
ppo_snapshot = _ppo_state.snapshot()
svc = _get_policy_service()
return {
"training": training_snapshot,
"ppo": ppo_snapshot,
"eval_results": eval_results,
"policy_loaded": svc._loaded if svc else False,
}
+96
View File
@@ -0,0 +1,96 @@
"""
routes/precon.py
================
Precon deck management endpoints:
GET /api/lab/precons
POST /api/lab/precons/install
POST /api/lab/precons/install-batch
POST /api/lab/precons/refresh
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from fastapi import APIRouter, HTTPException
import routes.shared as _shared # module reference, not value import
from routes.shared import CFG, download_precon_database, log
from services.precon_service import _get_precon_dir
router = APIRouter(tags=["precon"])
@router.get("/api/lab/precons")
async def list_precons():
"""List all available precon decks."""
return {"precons": _shared.PRECON_INDEX} # reads current value every call
@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"} or {"name": "Elven_Empire.dck"}
"""
file_name = req.get("fileName", "") or req.get("name", "")
if not file_name:
raise HTTPException(400, "fileName is required")
src = _get_precon_dir() / file_name
if not src.exists():
raise HTTPException(404, f"Precon not found: {file_name}")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
return {
"installed": True,
"deckName": deck_name,
"destination": str(dst),
"message": f"Installed {deck_name} to Forge decks",
}
@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", ...]} or {"names": ["Elven_Empire.dck", ...]}
"""
file_names = req.get("fileNames", []) or req.get("names", [])
if not file_names:
raise HTTPException(400, "fileNames list is required")
decks_dir = CFG.forge_decks_dir
if not decks_dir or not os.path.isdir(decks_dir):
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
results = []
for file_name in file_names:
src = _get_precon_dir() / file_name
if not src.exists():
results.append({"fileName": file_name, "installed": False, "error": "not found"})
continue
dst = Path(decks_dir) / file_name
shutil.copy2(str(src), str(dst))
deck_name = file_name.replace(".dck", "")
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
return {"results": results}
@router.post("/api/lab/precons/refresh")
async def refresh_precons():
"""Force re-download of all Commander precon decks from GitHub."""
result = download_precon_database(force=True)
if result.get("error"):
raise HTTPException(502, result["error"])
return {
"message": f"Downloaded {result['downloaded']} Commander precon decks",
"total": result.get("total", 0),
}
+481
View File
@@ -0,0 +1,481 @@
"""
routes/scanner.py
=================
Card scanner & collection enrichment endpoints:
POST /api/collection/scan
POST /api/collection/scan/add
POST /api/collection/re-enrich
POST /api/collection/auto-classify
POST /api/collection/auto-classify-all
GET /api/collection/{cardId}/edhrec
"""
from __future__ import annotations
import json
import os
import re
import time
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import URLError
from fastapi import APIRouter, HTTPException
from fastapi import Request as FastAPIRequest
from routes.shared import (
CFG, log_scan, log_deckgen,
_get_db_conn, _row_to_dict, _enrich_from_scryfall,
_scryfall_fuzzy_lookup, _detect_card_roles,
_to_edhrec_slug, _API_HEADERS,
_edhrec_cache_get, _edhrec_cache_set,
)
router = APIRouter(tags=["scanner"])
@router.post("/api/collection/scan")
async def scan_card_image(request: FastAPIRequest):
"""
Scan a card image and return recognized card name(s).
Uses Ximilar Visual AI for card recognition.
Accepts multipart/form-data with:
- file: image file (JPEG/PNG)
- mode: "single" (default) or "multi"
Returns:
{ "results": [ { raw_ocr, matched_name, set_code, scryfall_id, confidence, image_uri, error, collector_number, rarity, tcgplayer_url }, ... ] }
"""
try:
from scanner.pipeline import scan_single, scan_multi
except ImportError as e:
raise HTTPException(500, f"Scanner module not available: {e}")
# Try CFG first, then fall back to env var
api_key = CFG.ximilar_api_key or os.environ.get("XIMILAR_API_KEY", "")
if not api_key:
raise HTTPException(500, "Card scanner not configured. Start the server with --ximilar-key YOUR_KEY or set XIMILAR_API_KEY env var.")
# Parse multipart form
form = await request.form()
upload = form.get("file")
if upload is None:
raise HTTPException(400, "No file uploaded. Send an image as 'file' in multipart/form-data.")
mode = str(form.get("mode", "single")).lower()
image_bytes = await upload.read()
if len(image_bytes) == 0:
raise HTTPException(400, "Uploaded file is empty")
# Limit file size (20 MB)
if len(image_bytes) > 20 * 1024 * 1024:
raise HTTPException(400, "Image file too large (max 20 MB)")
log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
if mode == "multi":
results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
else:
result = scan_single(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
results = [result]
return {
"results": [r.to_dict() for r in results]
}
@router.post("/api/collection/scan/add")
async def scan_add_cards(body: dict):
"""
Add scanned cards to the collection.
Body:
{
"cards": [
{ "name": "Lightning Bolt", "set_code": "m11", "quantity": 1 },
...
]
}
"""
cards = body.get("cards", [])
if not cards:
raise HTTPException(400, "No cards provided")
conn = _get_db_conn()
imported = 0
updated = 0
errors = []
for card_req in cards:
name = str(card_req.get("name", "")).strip()
if not name:
continue
quantity = int(card_req.get("quantity", 1))
set_code = str(card_req.get("set_code", "")).lower()
collector_number = str(card_req.get("collector_number", ""))
finish = "NORMAL"
try:
enriched = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Scryfall error for '{name}': {e}")
continue
if not enriched or "_error" in enriched:
err_msg = enriched.get("_error", f"Not found: '{name}'") if enriched else f"Not found: '{name}'"
errors.append(err_msg)
continue
resolved_name = enriched.get("name", name)
# Check for existing entry
existing = conn.execute(
"""SELECT id, quantity FROM collection_entries
WHERE name = ? AND set_code = ? AND collector_number = ? AND finish = ?""",
(resolved_name, set_code, collector_number, finish),
).fetchone()
if existing:
new_qty = existing["quantity"] + quantity
conn.execute(
"UPDATE collection_entries SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
(new_qty, existing["id"]),
)
conn.commit()
updated += 1
else:
conn.execute(
"""INSERT INTO collection_entries
(name, type_line, subtypes, is_legendary, is_basic, color_identity,
cmc, mana_cost, oracle_text, keywords,
power, toughness, rarity, set_name, edhrec_rank,
tcg_price, salt_score, is_game_changer,
category, scryfall_id, tcgplayer_id,
quantity, finish, condition, language, notes, tags,
set_code, collector_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
resolved_name,
enriched.get("type_line", ""),
enriched.get("subtypes", "[]"),
enriched.get("is_legendary", 0),
enriched.get("is_basic", 0),
enriched.get("color_identity", "[]"),
enriched.get("cmc", 0.0),
enriched.get("mana_cost", ""),
enriched.get("oracle_text", ""),
enriched.get("keywords", "[]"),
enriched.get("power", ""),
enriched.get("toughness", ""),
enriched.get("rarity", ""),
enriched.get("set_name", ""),
enriched.get("edhrec_rank", 0),
enriched.get("tcg_price", 0.0),
enriched.get("salt_score", 0.0),
enriched.get("is_game_changer", 0),
enriched.get("category", "[]"),
enriched.get("scryfall_id", ""),
enriched.get("tcgplayer_id", ""),
quantity,
finish,
"", # condition
"", # language
"", # notes
"", # tags
set_code,
collector_number,
),
)
conn.commit()
imported += 1
return {
"importedCount": imported,
"updatedCount": updated,
"failedCount": len(errors),
"errors": errors,
}
# ══════════════════════════════════════════════════════════════
# Re-enrich Collection from Scryfall
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/re-enrich")
async def re_enrich_collection():
"""
Re-fetch Scryfall data for all collection entries to backfill
missing fields (mana_cost, power, toughness, rarity, set_name, edhrec_rank).
Also refreshes: type_line, oracle_text, keywords, cmc, color_identity, prices.
Runs synchronously — may take a while for large collections.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, name, set_code, collector_number FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
enriched_count = 0
skipped_count = 0
errors = []
for row in rows:
card_id = row["id"]
name = row["name"]
set_code = row["set_code"] or ""
collector_number = row["collector_number"] or ""
try:
data = _enrich_from_scryfall(name, set_code, collector_number)
except Exception as e:
errors.append(f"Error for '{name}': {e}")
skipped_count += 1
continue
if not data or "_error" in data:
err_msg = data.get("_error", f"Not found: '{name}'") if data else f"Not found: '{name}'"
errors.append(err_msg)
skipped_count += 1
continue
conn.execute(
"""UPDATE collection_entries SET
type_line = ?, subtypes = ?, is_legendary = ?, is_basic = ?,
color_identity = ?, cmc = ?, mana_cost = ?,
oracle_text = ?, keywords = ?,
power = ?, toughness = ?,
rarity = ?, set_name = ?, edhrec_rank = ?,
tcg_price = ?, scryfall_id = ?, tcgplayer_id = ?,
category = ?,
updated_at = datetime('now')
WHERE id = ?""",
(
data.get("type_line", ""),
data.get("subtypes", "[]"),
data.get("is_legendary", 0),
data.get("is_basic", 0),
data.get("color_identity", "[]"),
data.get("cmc", 0.0),
data.get("mana_cost", ""),
data.get("oracle_text", ""),
data.get("keywords", "[]"),
data.get("power", ""),
data.get("toughness", ""),
data.get("rarity", ""),
data.get("set_name", ""),
data.get("edhrec_rank", 0),
data.get("tcg_price", 0.0),
data.get("scryfall_id", ""),
data.get("tcgplayer_id", ""),
data.get("category", "[]"),
card_id,
),
)
conn.commit()
enriched_count += 1
return {
"total": total,
"enrichedCount": enriched_count,
"skippedCount": skipped_count,
"errors": errors[:50], # cap error list
}
# ══════════════════════════════════════════════════════════════
# Auto-Classify Collection
# ══════════════════════════════════════════════════════════════
@router.post("/api/collection/auto-classify")
async def auto_classify_collection():
"""
Run auto-classification on all collection entries.
Uses oracle_text, type_line, and keywords to detect functional roles
(Ramp, Draw, Removal, Board Wipe, Anthem, Stax, etc.).
Only updates cards whose current category is empty or '[]'.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords, category FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
skipped_count = 0
for row in rows:
card_id = row["id"]
existing_cats = row["category"] or "[]"
# Parse existing categories
try:
cats = json.loads(existing_cats)
except Exception:
cats = []
# Skip cards that already have manually-set categories
if cats:
skipped_count += 1
continue
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
if roles:
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"skippedCount": skipped_count,
"message": f"Classified {classified_count} cards, skipped {skipped_count} (already had categories)",
}
@router.post("/api/collection/auto-classify-all")
async def auto_classify_all_collection():
"""
Force re-classify ALL collection entries, overwriting existing categories.
"""
conn = _get_db_conn()
rows = conn.execute(
"SELECT id, oracle_text, type_line, keywords FROM collection_entries ORDER BY id"
).fetchall()
total = len(rows)
classified_count = 0
for row in rows:
card_id = row["id"]
oracle_text = row["oracle_text"] or ""
type_line = row["type_line"] or ""
keywords = row["keywords"] or "[]"
roles = _detect_card_roles(oracle_text, type_line, keywords)
conn.execute(
"UPDATE collection_entries SET category = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(roles), card_id),
)
classified_count += 1
conn.commit()
return {
"total": total,
"classifiedCount": classified_count,
"message": f"Re-classified all {classified_count} cards",
}
# ══════════════════════════════════════════════════════════════
# EDHREC Recommendations Cache + Endpoint
# ══════════════════════════════════════════════════════════════
@router.get("/api/collection/{cardId}/edhrec")
async def get_card_edhrec(cardId: int):
"""
Get EDHREC recommendations for a collection card.
For legendary creatures (potential commanders): fetch commander synergy data.
For other cards: fetch 'also played with' data.
"""
conn = _get_db_conn()
row = conn.execute("SELECT * FROM collection_entries WHERE id = ?", (cardId,)).fetchone()
if not row:
raise HTTPException(404, f"Card with id {cardId} not found")
card = _row_to_dict(row)
name = card["name"]
type_line = card.get("type_line", "")
is_legendary = card.get("is_legendary", 0)
# Determine if it's a legendary creature (potential commander)
is_commander = bool(
is_legendary and
re.search(r"\bCreature\b", type_line, re.IGNORECASE)
)
slug = _to_edhrec_slug(name)
cache_key = f"edhrec:{'cmd' if is_commander else 'card'}:{slug}"
cached = _edhrec_cache_get(cache_key)
if cached:
return cached
recommendations = []
links = {
"edhrecPage": (
f"https://edhrec.com/commanders/{slug}"
if is_commander
else f"https://edhrec.com/cards/{slug}"
),
"archidektSearch": f"https://archidekt.com/search/cards?q={name.replace(' ', '+')}",
"moxfieldSearch": f"https://www.moxfield.com/search?q={name.replace(' ', '+')}",
}
try:
if is_commander:
url = f"https://json.edhrec.com/pages/commanders/{slug}.json"
else:
url = f"https://json.edhrec.com/pages/cards/{slug}.json"
req = Request(url, headers=_API_HEADERS)
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
if is_commander:
# Extract top cards from cardlists
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Recommended",
})
else:
# Extract "also played with" cards
container = data.get("container", {})
json_dict = container.get("json_dict", {})
for cardlist in json_dict.get("cardlists", []):
tag = cardlist.get("tag", "")
for cv in cardlist.get("cardviews", [])[:10]:
rec_name = cv.get("name", "")
if not rec_name:
continue
synergy = cv.get("synergy", 0.0)
recommendations.append({
"name": rec_name,
"synergy": synergy,
"role": tag or "Also Played With",
})
except Exception as e:
log_deckgen.error(f" Error fetching data for '{name}': {e}")
# Graceful degradation — return empty results
result = {
"recommendations": recommendations[:30],
"links": links,
}
_edhrec_cache_set(cache_key, result)
return result
+204
View File
@@ -0,0 +1,204 @@
"""
routes/shared.py
================
Single source of truth for everything every router needs.
Contains:
- Config class + CFG singleton
- Logging setup + all named loggers
- FastAPI app instance
- In-memory state (active_batches, COMMANDER_META, PRECON_INDEX, AI_PROFILES)
- All Pydantic request/response models
- SQLite DB helpers (_get_db_conn, init_collection_db)
- Scryfall cache + enrichment helpers
- Collection helpers (_detect_card_roles, _classify_card_type,
_build_collection_filters, _parse_finish, _parse_csv_content,
_parse_text_line, _auto_infer_mapping, _row_to_dict, _add_image_url,
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS)
- Deck helpers (_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
_TYPE_TARGETS, _TYPE_PRIORITY, _classify_card_type)
- Import helpers (_import_from_url, _fetch_archidekt_deck,
_fetch_edhrec_average, _parse_text_decklist, _save_profile_to_dck,
_to_edhrec_slug, _http_get, _API_HEADERS, _edhrec_cache_get,
_edhrec_cache_set)
- Precon helpers (PRECON_DIR, PRECON_INDEX, download_precon_database,
load_precon_index, GITHUB_PRECON_URL, PRECON_CACHE_HOURS)
- Sim helpers (build_java_command, get_java17, BatchState, parse_dck_file,
AI_PROFILES, run_batch_subprocess, _run_process_blocking,
_run_deepseek_batch_thread, _load_deck_cards_by_name, _get_deepseek_brain)
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import io
import json
import logging
import logging.handlers
import os
import re
import sqlite3
import subprocess
import sys
import threading
import time
import uuid
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.request import urlopen, Request
from urllib.error import URLError
try:
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request as FastAPIRequest
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
except ImportError:
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
sys.exit(1)
# ══════════════════════════════════════════════════════════════
# Configuration
# ══════════════════════════════════════════════════════════════
# Configuration (imported from models/state)
from models.state import Config, CFG
# ══════════════════════════════════════════════════════════════
# Logging Setup
# ══════════════════════════════════════════════════════════════
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
def setup_logging(level: int = logging.INFO) -> None:
_LOG_DIR.mkdir(parents=True, exist_ok=True)
root_logger = logging.getLogger("commander_ai_lab")
root_logger.setLevel(level)
if root_logger.handlers:
return
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
console = logging.StreamHandler(sys.stdout)
console.setLevel(level)
console.setFormatter(formatter)
root_logger.addHandler(console)
file_handler = logging.handlers.RotatingFileHandler(
_LOG_DIR / "commander-ai-lab.log",
maxBytes=_LOG_MAX_BYTES,
backupCount=_LOG_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
log = logging.getLogger("commander_ai_lab.api")
log_batch = logging.getLogger("commander_ai_lab.batch")
log_sim = logging.getLogger("commander_ai_lab.sim")
log_coach = logging.getLogger("commander_ai_lab.coach")
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
log_collect = logging.getLogger("commander_ai_lab.collection")
log_scan = logging.getLogger("commander_ai_lab.scanner")
log_ml = logging.getLogger("commander_ai_lab.ml")
log_cache = logging.getLogger("commander_ai_lab.cache")
log_pplx = logging.getLogger("commander_ai_lab.pplx")
# ══════════════════════════════════════════════════════════════
# In-Memory State
# ══════════════════════════════════════════════════════════════
# In-Memory State (imported from models/state)
from models.state import (
BatchState, active_batches, COMMANDER_META,
BUILTIN_COMMANDERS, load_commander_meta,
)
# Pydantic Models (imported from models/)
from models.requests import (
StartRequest, ImportUrlRequest, ImportTextRequest, MetaFetchRequest,
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
DeckGenerationSourceConfig, DeckGenerationRequest,
DeckGenV3Request, DeckGenV3SubstituteRequest,
)
from models.responses import (
StartResponse, StatusResponse, DeckInfo, GeneratedDeckCard,
)
# Collection Database (imported from services/database.py)
from services.database import (
COLLECTION_DB_PATH, _get_db_conn, init_collection_db,
_JSON_FIELDS, VALID_SORT_FIELDS,
_row_to_dict, _snake_to_camel, _add_image_url, _build_collection_filters,
)
# Card Role Detection (imported from services/card_analysis.py)
from services.card_analysis import _detect_card_roles
# ══════════════════════════════════════════════════════════════
# Deck Helpers (imported from services/deck_service.py)
from services.deck_service import (
_TYPE_PRIORITY, _TYPE_TARGETS, _classify_card_type, _get_deck_or_404,
_compute_deck_analysis, _check_ratio_limit, _to_edhrec_slug,
parse_dck_file, _save_profile_to_dck, _load_deck_cards_by_name,
)
# ══════════════════════════════════════════════════════════════
# Scryfall Cache (imported from services/scryfall.py)
from services.scryfall import (
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
_ScryfallCache, _scryfall_cache, _scryfall_lock, _scryfall_last_call,
_scryfall_rate_limit, _fetch_scryfall_api, _enrich_from_scryfall, _scryfall_fuzzy_lookup,
)
# ══════════════════════════════════════════════════════════════
# Import Helpers (extracted to services/import_helpers.py)
from services.import_helpers import (
_http_get, _fetch_archidekt_deck, _fetch_edhrec_average,
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
_edhrec_cache, _EDHREC_CACHE_TTL, _edhrec_cache_get, _edhrec_cache_set,
)
# ══════════════════════════════════════════════════════════════
# Collection Import Helpers (extracted to services/import_service.py)
from services.import_service import (
_parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content,
)
# ══════════════════════════════════════════════════════════════
# Precon Helpers (extracted to services/precon_service.py)
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,
)
# ══════════════════════════════════════════════════════════════
# AI Profiles (imported from models/state)
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,
run_batch_subprocess, _run_process_blocking,
_run_deepseek_batch_thread, _get_deepseek_brain,
)
_ml_logging_enabled = True
+6 -2
View File
@@ -13,6 +13,10 @@ No local OCR or OpenCV needed — all recognition is done by the
Ximilar cloud API using visual AI.
"""
from typing import Callable, Optional
import logging
log = logging.getLogger("commander_ai_lab.scanner")
from .ximilar_client import identify_cards, XimilarResult
@@ -160,7 +164,7 @@ def _ximilar_to_scan_result(xr: XimilarResult,
try:
scryfall_data = scryfall_lookup(xr.name)
except Exception as e:
print(f" [SCAN] Scryfall lookup failed for '{xr.name}': {e}")
log.error(f" Scryfall lookup failed for '{xr.name}': {e}")
if scryfall_data and scryfall_data.get("object") != "error":
result.scryfall_id = scryfall_data.get("id", "")
@@ -181,6 +185,6 @@ def _ximilar_to_scan_result(xr: XimilarResult,
# Extract image from scryfall URL pattern
# e.g., https://scryfall.com/card/m11/149/lightning-bolt
result.image_uri = "" # Will be fetched by frontend via scryfall_id
print(f" [SCAN] No Scryfall match for '{xr.name}' — using Ximilar data only")
log.info(f" No Scryfall match for '{xr.name}' — using Ximilar data only")
return result
+8 -4
View File
@@ -13,6 +13,10 @@ import json
from typing import Optional, List
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import logging
log = logging.getLogger("commander_ai_lab.scanner.ximilar")
XIMILAR_ENDPOINT = "https://api.ximilar.com/collectibles/v2/tcg_id"
@@ -82,7 +86,7 @@ def identify_cards(image_bytes: bytes, api_key: str,
)
try:
print(f" [XIMILAR] Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
log.info(f" Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
with urlopen(req, timeout=60) as resp:
resp_data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
@@ -168,12 +172,12 @@ def _parse_response(resp_data: dict) -> List[XimilarResult]:
if not results:
results.append(XimilarResult(error="Ximilar could not identify any cards"))
print(f" [XIMILAR] Identified {len(results)} card(s)")
log.info(f" Identified {len(results)} card(s)")
for r in results:
if r.name:
print(f" [XIMILAR] - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
log.info(f" - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
elif r.error:
print(f" [XIMILAR] - ERROR: {r.error}")
log.error(f" - ERROR: {r.error}")
return results
+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()
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff. Show more