Compare commits

..
Author SHA1 Message Date
KoalaTrapLord 34e88a3a14 fix: correct indentation of commander_keywords and oracle overlap scoring in deckgen.py 2026-03-25 08:45:51 -04:00
KoalaTrapLord f199bcfa34 feat: column visibility toggle UI for collection table 2026-03-25 08:31:12 -04:00
KoalaTrapLord 77c92d569d fix: correct indentation of elif/else branches in _run_sim_thread_deepseek 2026-03-25 08:24:21 -04:00
KoalaTrapLord f24bd05be8 feat: collection stats dashboard (CMC curve, color/type/rarity/value) 2026-03-25 07:51:09 -04:00
KoalaTrapLord e7ffccf3e8 feat: persist coach sessions to SQLite with disk fallback 2026-03-24 23:44:20 -04:00
KoalaTrapLord 64a65ca2f6 feat: auto-fill deck name with Auto - {CommanderName} on select 2026-03-24 23:19:17 -04:00
KoalaTrapLord 3ba44f68d9 feat: oracle-text keyword overlap scoring in _generate_deck 2026-03-24 22:56:30 -04:00
KoalaTrapLord f851df6dbf fix: add getBaseUrl(), PolicyDecision record, and decide(String,int) to PolicyClient
GameSession.java references these APIs which were missing from
PolicyClient, causing 3 compilation errors. Added:
- getBaseUrl() getter
- PolicyDecision record (action, confidence)
- decide(String, int) overload that delegates to decide(JsonObject)
2026-03-24 22:46:45 -04:00
KoalaTrapLord ded79bfde1 feat: add omit_cards chips styles to deck generator CSS 2026-03-24 22:20:10 -04:00
KoalaTrapLord 411e40e46e feat: add omit_cards chips panel to deck generator HTML 2026-03-24 22:17:25 -04:00
KoalaTrapLord a4e49daf49 feat: add omit_cards chips/tags input to deck generator JS 2026-03-24 22:11:36 -04:00
KoalaTrapLord 675c64029b feat: add progress bar + ETA to deck generation UI 2026-03-24 21:20:47 -04:00
KoalaTrapLord 4c47b9d5e2 fix: add V3 result cache to avoid re-generating deck on export 2026-03-24 21:07:59 -04:00
KoalaTrapLord 8903432d67 fix: add missing export filter params (rarity, setCode, power, toughness, keyword, edhrec, qty) 2026-03-24 21:01:35 -04:00
KoalaTrapLord b1e473397f fix: shopping list field name + scan-add refresh function name 2026-03-24 20:53:19 -04:00
KoalaTrapLord 07b2c9d5a9 fix: add missing LLM_TIMEOUT constant in coach.py
LLM_TIMEOUT was referenced but never defined, causing a NameError
in the Ollama streaming SSE code path when COACH_PROVIDER != 'perplexity'.
2026-03-24 20:43:36 -04:00
KoalaTrapLord 58bbb514ad fix: skip ML decision flush when brain not connected (heuristic fallback guard) 2026-03-24 20:27:02 -04:00
KoalaTrapLord d70d74d753 feat: add Scryfall on-the-fly embedding fallback for unknown cards 2026-03-24 20:09:27 -04:00
KoalaTrapLord 475849e477 fix: prevent double-flush of ML decisions in deepseek sim thread
_run_sim_thread_deepseek was manually calling engine.flush_ml_decisions()
and writing the .jsonl, then passing engine=engine to _finish_sim which
called flush_ml_decisions() a second time — returning an empty list and
producing empty training data files.

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

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

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

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

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

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

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

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

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

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

Includes Forge phase mapping and PlayerZoneState with full zone contents.

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

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

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

Closes #61

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Partial fix for Issue #86 item 2.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+22 -1
View File
@@ -1,4 +1,4 @@
# Commander AI Lab — Environment Variables
# Commander AI Lab - Environment Variables
# Copy this file to .env and fill in your API keys.
# .env is gitignored and will NOT be committed.
@@ -9,7 +9,28 @@ 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
# Perplexity model for deck research & generation (sonar / sonar-pro / sonar-deep-research)
PPLX_MODEL=sonar-pro
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
COACH_PROVIDER=local
# CORS allowed origins (comma-separated)
# Default: http://localhost:5173,http://localhost:8080
# Add your LAN IP if accessing from another machine on your network
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
# ── Dual-Model AI Brain ─────────────────────────────────────
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
BRAIN_API_BASE=http://localhost:11434
BRAIN_MODEL=gpt-oss:20b
BRAIN_TEMPERATURE=0.3
BRAIN_MAX_TOKENS=1024
BRAIN_TIMEOUT=120.0
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
VALIDATOR_ENABLED=true
VALIDATOR_API_BASE=http://localhost:11434
VALIDATOR_MODEL=deepseek-r1:14b
VALIDATOR_TEMPERATURE=0.0
VALIDATOR_MAX_TOKENS=2048
VALIDATOR_TIMEOUT=120.0
-20
View File
@@ -33,26 +33,6 @@ jobs:
- name: Run tests
run: pytest -v --tb=short tests/ || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run frontend tests
working-directory: frontend
run: npx vitest run
java-tests:
runs-on: ubuntu-latest
steps:
+96 -11
View File
@@ -9,6 +9,8 @@ calls the LLM, parses responses, and persists coaching sessions.
import json
import logging
import uuid
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict
@@ -16,6 +18,7 @@ from typing import Optional, List, Dict
from .config import (
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
COACH_PROVIDER, PPLX_MODEL,
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
)
from .models import (
@@ -28,6 +31,18 @@ from .prompt_template import build_system_prompt, build_user_prompt
logger = logging.getLogger("coach.service")
# SQLite persistence for coach sessions
try:
from services.database import (
save_coach_session as _db_save_session,
load_coach_session as _db_load_session,
list_coach_sessions as _db_list_sessions,
delete_coach_session as _db_delete_session,
)
_DB_AVAILABLE = True
except ImportError:
_DB_AVAILABLE = False
class CoachService:
"""
@@ -55,7 +70,7 @@ class CoachService:
if not report_path.exists():
# Try case-insensitive match
for f in DECK_REPORTS_DIR.glob("*.json"):
if f.stem.lower() == deck_id.lower():
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
report_path = f
break
@@ -80,15 +95,62 @@ class CoachService:
# ── Coach Session I/O ──────────────────────────────────────
def save_session(self, session: CoachSession) -> Path:
"""Persist a coaching session to disk."""
"""Persist a coaching session to disk and SQLite."""
# 1. Write JSON file (backward compat)
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
with open(path, "w") as f:
f.write(session.model_dump_json(indent=2))
# 2. Write to SQLite
if _DB_AVAILABLE:
try:
_db_save_session({
"session_id": session.sessionId,
"deck_id": session.deckId,
"timestamp": session.timestamp,
"model_used": session.modelUsed or "",
"prompt_tokens": session.promptTokens or 0,
"completion_tokens": session.completionTokens or 0,
"summary": session.summary or "",
"goals_json": json.dumps(session.goals.model_dump() if session.goals else {}),
"cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]),
"adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]),
"heuristic_hints_json": json.dumps(session.heuristicHints or []),
"mana_base_advice": session.manaBaseAdvice or "",
"raw_text": session.rawTextExplanation or "",
})
logger.info("Coach session persisted to SQLite: %s", session.sessionId)
except Exception as e:
logger.warning("Failed to persist session to SQLite: %s", e)
return path
def load_session(self, session_id: str) -> Optional[CoachSession]:
"""Load a coaching session from disk."""
"""Load a coaching session from SQLite, falling back to disk."""
# Try SQLite first
if _DB_AVAILABLE:
try:
row = _db_load_session(session_id)
if row:
data = dict(row)
return CoachSession(
sessionId=data["session_id"],
deckId=data["deck_id"],
timestamp=data["timestamp"],
modelUsed=data.get("model_used", ""),
promptTokens=data.get("prompt_tokens", 0),
completionTokens=data.get("completion_tokens", 0),
summary=data.get("summary", ""),
goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None,
suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))],
suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))],
heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")),
manaBaseAdvice=data.get("mana_base_advice", ""),
rawTextExplanation=data.get("raw_text", ""),
)
except Exception as e:
logger.warning("SQLite load failed for %s: %s", session_id, e)
# Fallback to disk
path = COACH_SESSIONS_DIR / f"{session_id}.json"
if not path.exists():
return None
@@ -100,8 +162,18 @@ class CoachService:
logger.error("Failed to load session %s: %s", session_id, e)
return None
def list_sessions(self, deck_id: str = None) -> List[dict]:
"""List all coaching sessions, optionally filtered by deck."""
"""List coaching sessions from SQLite, falling back to disk."""
# Try SQLite first
if _DB_AVAILABLE:
try:
rows = _db_list_sessions(deck_id)
if rows is not None:
return rows
except Exception as e:
logger.warning("SQLite list_sessions failed: %s", e)
# Fallback to disk
if not COACH_SESSIONS_DIR.exists():
return []
sessions = []
@@ -123,6 +195,7 @@ class CoachService:
continue
return sessions
# ── Main Coaching Pipeline ─────────────────────────────────
async def run_coaching_session(self, deck_id: str,
@@ -265,13 +338,25 @@ class CoachService:
"""Check the health of all coach subsystems."""
status = CoachStatus()
# Check LLM
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "LM Studio not reachable")
# Check LLM / Perplexity provider
if COACH_PROVIDER == "perplexity":
pplx_key = os.environ.get("PPLX_API_KEY", "")
if pplx_key:
status.llmConnected = True
status.llmModel = f"Perplexity ({PPLX_MODEL})"
status.llmModels = [PPLX_MODEL]
else:
status.llmConnected = False
status.llmModel = None
status.llmModels = []
status.error = "Perplexity API key not configured. Set PPLX_API_KEY env var."
else:
llm_status = self.llm.check_connection()
status.llmConnected = llm_status.get("connected", False)
status.llmModel = llm_status.get("active_model")
status.llmModels = llm_status.get("models", [])
if not status.llmConnected:
status.error = llm_status.get("error", "Local LLM not reachable")
# Check embeddings
status.embeddingsLoaded = self.embeddings.loaded
+10 -6
View File
@@ -13,9 +13,9 @@ from pathlib import Path
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
# ── Ollama (OpenAI-compatible API) ──────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "120")) # seconds
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
LLM_MAX_RETRIES = 3
# ── Embeddings ─────────────────────────────────────────────
@@ -28,9 +28,9 @@ DECK_REPORTS_DIR = LAB_ROOT / "deck-reports"
COACH_SESSIONS_DIR = LAB_ROOT / "coach-sessions"
# ── Prompt Limits ──────────────────────────────────────────
MAX_PROMPT_TOKENS = 4096
MAX_CANDIDATES_PER_UNDERPERFORMER = 10
MAX_UNDERPERFORMERS = 8
MAX_PROMPT_TOKENS = 2048
MAX_CANDIDATES_PER_UNDERPERFORMER = 5
MAX_UNDERPERFORMERS = 4
UNDERPERFORMER_IMPACT_THRESHOLD = -0.05 # impactScore below this = underperformer
# ── LLM Generation Settings (Coach — Ollama) ──────────────
@@ -44,7 +44,11 @@ DEFAULT_MAX_TOKENS = 8192
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
# Perplexity cloud model (sonar / sonar-pro / sonar-deep-research)
PPLX_MODEL = os.environ.get("PPLX_MODEL", "sonar-pro")
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "local")
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
+237 -41
View File
@@ -9,6 +9,11 @@ Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
Model: Alibaba-NLP/gte-modernbert-base
Unknown cards (e.g. newly printed sets) are fetched from Scryfall,
embedded on-the-fly using the same model, and persisted to a local
custom cache (embeddings/mtg_embeddings_custom.npz) so they survive
restarts without re-hitting Scryfall.
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
"""
@@ -16,9 +21,11 @@ import json
import logging
import re
import os
import time
from pathlib import Path
from typing import List, Optional, Tuple
from urllib.request import urlopen, Request
from urllib.parse import quote
import numpy as np
@@ -32,8 +39,16 @@ HF_PARQUET_URL = (
"resolve/main/mtg_embeddings.parquet"
)
# ── Scryfall API ───────────────────────────────────────────
SCRYFALL_NAMED_URL = "https://api.scryfall.com/cards/named?fuzzy={}"
# ── Custom cache for on-the-fly embedded cards ─────────────
CUSTOM_CACHE_PATH = EMBEDDINGS_DIR / "mtg_embeddings_custom.npz"
# ── Embedding model (same as minimaxir/mtg-embeddings) ─────
EMBEDDING_MODEL = "Alibaba-NLP/gte-modernbert-base"
# ── Color identity parsing ─────────────────────────────────
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
MANA_SYMBOL_COLORS = {
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
}
@@ -46,7 +61,6 @@ def _parse_color_identity(mana_cost: str) -> str:
colors = set()
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
colors.add(match.group(1))
# Sort in WUBRG order
order = "WUBRG"
return "".join(c for c in order if c in colors)
@@ -61,12 +75,11 @@ def _parse_mana_value(mana_cost: str) -> float:
if symbol in MANA_SYMBOL_COLORS:
total += 1.0
elif symbol == "X":
pass # X = 0 for CMC purposes
pass
else:
try:
total += float(symbol)
except ValueError:
# Hybrid/phyrexian symbols count as 1
total += 1.0
return total
@@ -75,7 +88,42 @@ def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
"""Check if card's color identity is within deck's color identity."""
deck_set = set(deck_colors)
card_set = set(card_colors)
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
return card_set.issubset(deck_set) or len(card_set) == 0
def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
"""Fetch card data from Scryfall by fuzzy name match.
Returns a dict with keys: name, mana_cost, type_line, oracle_text,
color_identity. Returns None on any error.
"""
url = SCRYFALL_NAMED_URL.format(quote(card_name))
try:
req = Request(url, headers={"User-Agent": "commander-ai-lab/1.0"})
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
return {
"name": data.get("name", card_name),
"mana_cost": data.get("mana_cost", ""),
"type_line": data.get("type_line", ""),
"oracle_text": data.get("oracle_text", ""),
"color_identity": "".join(data.get("color_identity", [])),
}
except Exception as e:
logger.warning("Scryfall lookup failed for '%s': %s", card_name, e)
return None
def _build_card_text(card: dict) -> str:
"""Build the text string to embed, matching minimaxir/mtg-embeddings format."""
parts = [card["name"]]
if card.get("mana_cost"):
parts.append(card["mana_cost"])
if card.get("type_line"):
parts.append(card["type_line"])
if card.get("oracle_text"):
parts.append(card["oracle_text"])
return " | ".join(parts)
class CardMatch:
@@ -114,15 +162,17 @@ class MTGEmbeddingIndex:
"""
def __init__(self):
self.names: np.ndarray = None # (N,) string array
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
self.mana_values: np.ndarray = None # (N,) float array
self.types: np.ndarray = None # (N,) string array
self.mana_costs: np.ndarray = None # (N,) string array
self.texts: np.ndarray = None # (N,) string array
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
self.names: np.ndarray = None
self.colors: np.ndarray = None
self.mana_values: np.ndarray = None
self.types: np.ndarray = None
self.mana_costs: np.ndarray = None
self.texts: np.ndarray = None
self.vectors: np.ndarray = None
self._name_to_idx: dict = {}
self._loaded = False
self._sentence_model = None # lazy-loaded only when needed
self._custom_dirty = False # tracks whether custom cache needs saving
@property
def loaded(self) -> bool:
@@ -152,10 +202,7 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_PARQUET
def convert_parquet_to_npz(self) -> Path:
"""
Convert the Parquet file to a compact NPZ for fast loading.
Requires pandas and pyarrow.
"""
"""Convert the Parquet file to a compact NPZ for fast loading."""
try:
import pandas as pd
except ImportError:
@@ -169,14 +216,11 @@ class MTGEmbeddingIndex:
types = df["type"].fillna("").values.astype(str)
texts = df["text"].fillna("").values.astype(str)
# Parse color identities from mana costs
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
# Extract embedding vectors
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
# L2 normalize for cosine similarity via dot product
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1.0
embeddings = embeddings / norms
@@ -198,25 +242,27 @@ class MTGEmbeddingIndex:
return EMBEDDINGS_NPZ
def load(self, force_download: bool = False) -> bool:
"""
Load embeddings from NPZ (fast) or fall back to downloading
"""Load embeddings from NPZ (fast) or fall back to downloading
and converting from Parquet.
"""
if self._loaded and not force_download:
return True
# Try NPZ first (fast path)
if EMBEDDINGS_NPZ.exists() and not force_download:
return self._load_npz()
ok = self._load_npz()
else:
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
ok = self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
# Download and convert
try:
self.download_parquet(force=force_download)
self.convert_parquet_to_npz()
return self._load_npz()
except Exception as e:
logger.error("Failed to load embeddings: %s", e)
return False
if ok:
self._load_custom_cache()
return ok
def _load_npz(self) -> bool:
"""Load from pre-built NPZ file."""
@@ -230,7 +276,6 @@ class MTGEmbeddingIndex:
self.texts = data.get("texts", np.array([""] * len(self.names)))
self.vectors = data["vectors"]
# Build name lookup
self._name_to_idx = {
name.lower(): i for i, name in enumerate(self.names)
}
@@ -241,12 +286,169 @@ class MTGEmbeddingIndex:
logger.error("Failed to load NPZ: %s", e)
return False
# ── Custom cache ──────────────────────────────────────────
def _load_custom_cache(self) -> None:
"""Merge on-the-fly embedded cards from the custom cache into the index."""
if not CUSTOM_CACHE_PATH.exists():
return
try:
data = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
c_names = data["names"]
# Only add cards that aren't already in the main index
new_mask = np.array(
[n.lower() not in self._name_to_idx for n in c_names]
)
if not new_mask.any():
return
self._append_cards(
c_names[new_mask],
data["colors"][new_mask],
data["mana_values"][new_mask],
data["types"][new_mask],
data["mana_costs"][new_mask],
data["texts"][new_mask],
data["vectors"][new_mask],
)
logger.info("Loaded %d custom-cached card embeddings", new_mask.sum())
except Exception as e:
logger.warning("Failed to load custom embedding cache: %s", e)
def _save_custom_cache(self) -> None:
"""Persist the custom cache NPZ (only cards not in the base dataset)."""
if not self._custom_dirty:
return
try:
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
# Collect indices that correspond to custom-added cards
# We track these by loading the base count at load time — simpler:
# just re-save the full custom cache from CUSTOM_CACHE_PATH if it
# exists, merging with any new cards we've added this session.
existing_names: set = set()
existing: dict = {}
if CUSTOM_CACHE_PATH.exists():
try:
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
for n in d["names"]:
existing_names.add(n.lower())
existing = dict(d)
except Exception:
pass
# Find names in our live index that aren't in the base NPZ
base_data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
base_names = set(n.lower() for n in base_data["names"])
custom_mask = np.array(
[n.lower() not in base_names for n in self.names]
)
if not custom_mask.any():
return
np.savez_compressed(
str(CUSTOM_CACHE_PATH),
names=self.names[custom_mask],
colors=self.colors[custom_mask],
mana_values=self.mana_values[custom_mask],
types=self.types[custom_mask],
mana_costs=self.mana_costs[custom_mask],
texts=self.texts[custom_mask],
vectors=self.vectors[custom_mask],
)
self._custom_dirty = False
logger.info("Saved custom embedding cache: %d cards", custom_mask.sum())
except Exception as e:
logger.warning("Failed to save custom embedding cache: %s", e)
def _append_cards(
self,
names: np.ndarray,
colors: np.ndarray,
mana_values: np.ndarray,
types: np.ndarray,
mana_costs: np.ndarray,
texts: np.ndarray,
vectors: np.ndarray,
) -> None:
"""Append new card arrays to the live in-memory index."""
start_idx = len(self.names)
self.names = np.concatenate([self.names, names])
self.colors = np.concatenate([self.colors, colors])
self.mana_values = np.concatenate([self.mana_values, mana_values])
self.types = np.concatenate([self.types, types])
self.mana_costs = np.concatenate([self.mana_costs, mana_costs])
self.texts = np.concatenate([self.texts, texts])
self.vectors = np.concatenate([self.vectors, vectors], axis=0)
for i, name in enumerate(names):
self._name_to_idx[name.lower()] = start_idx + i
# ── Sentence model (lazy) ─────────────────────────────────
def _get_sentence_model(self):
"""Lazy-load the sentence-transformers model on first cache miss."""
if self._sentence_model is None:
try:
from sentence_transformers import SentenceTransformer
logger.info("Loading embedding model %s (first cache miss)...", EMBEDDING_MODEL)
self._sentence_model = SentenceTransformer(EMBEDDING_MODEL)
logger.info("Embedding model loaded.")
except Exception as e:
logger.error("Failed to load SentenceTransformer: %s", e)
return None
return self._sentence_model
# ── On-the-fly Scryfall fallback ──────────────────────────
def _fetch_and_embed_card(self, card_name: str) -> Optional[np.ndarray]:
"""Fetch card from Scryfall, embed it, append to index, persist to cache.
Returns the L2-normalized embedding vector on success, None on failure.
"""
logger.info("Fetching unknown card from Scryfall: '%s'", card_name)
card_data = _fetch_scryfall_card(card_name)
if card_data is None:
return None
model = self._get_sentence_model()
if model is None:
return None
text = _build_card_text(card_data)
vec = model.encode(text, normalize_embeddings=True).astype(np.float32)
# Ensure shape (1, 768)
vec = vec.reshape(1, -1)
resolved_name = card_data["name"]
mana_cost = card_data.get("mana_cost", "")
color_identity = card_data.get("color_identity", "") or _parse_color_identity(mana_cost)
self._append_cards(
names=np.array([resolved_name]),
colors=np.array([color_identity]),
mana_values=np.array([_parse_mana_value(mana_cost)], dtype=np.float32),
types=np.array([card_data.get("type_line", "")]),
mana_costs=np.array([mana_cost]),
texts=np.array([card_data.get("oracle_text", "")]),
vectors=vec,
)
self._custom_dirty = True
self._save_custom_cache()
logger.info("Embedded and cached new card: '%s'", resolved_name)
return self.vectors[self._name_to_idx[resolved_name.lower()]]
# ── Public API ────────────────────────────────────────────
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
"""Get the embedding vector for a card by name (case-insensitive)."""
"""Get the embedding vector for a card by name (case-insensitive).
On a cache miss, fetches from Scryfall and embeds on the fly.
"""
idx = self._name_to_idx.get(card_name.lower())
if idx is not None:
return self.vectors[idx]
return None
# Cache miss — try Scryfall fallback
return self._fetch_and_embed_card(card_name)
def search_similar(self, query_card: str,
color_filter: List[str] = None,
@@ -274,38 +476,32 @@ class MTGEmbeddingIndex:
query_vec = self.get_card_vector(query_card)
if query_vec is None:
logger.warning("Card not found in embeddings: %s", query_card)
logger.warning("Card not found in embeddings and Scryfall fallback failed: %s", query_card)
return []
# Compute similarities (dot product on normalized vectors = cosine similarity)
similarities = self.vectors @ query_vec
# Build exclusion set
exclude_set = set()
exclude_set.add(query_card.lower())
if exclude_cards:
exclude_set.update(name.lower() for name in exclude_cards)
# Filter and collect results
results = []
for idx in np.argsort(similarities)[::-1]:
name = str(self.names[idx])
if name.lower() in exclude_set:
continue
# Color filter
if color_filter:
card_colors = str(self.colors[idx])
if not _is_color_subset(card_colors, color_filter):
continue
# Type filter
if type_filter:
card_type = str(self.types[idx])
if type_filter.lower() not in card_type.lower():
continue
# Mana value range filter
if mana_range:
mv = float(self.mana_values[idx])
if mv < mana_range[0] or mv > mana_range[1]:
+2 -2
View File
@@ -38,7 +38,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
output_path.mkdir(parents=True, exist_ok=True)
# Load all batch results
batch_files = sorted(results_path.glob("batch-*.json"))
batch_files = sorted(results_path.glob("ml-decision-*.json"))
if not batch_files:
logger.info("No batch result files found in %s", results_dir)
return []
@@ -108,7 +108,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
# Also load existing batch results for comprehensive reports
results_dir = Path(batch_json_path).parent
all_batches = []
for bf in sorted(results_dir.glob("batch-*.json")):
for bf in sorted(results_dir.glob("ml-decision-*.json")):
try:
with open(bf, "r", encoding="utf-8") as f:
all_batches.append(json.load(f))
+25
View File
@@ -256,6 +256,19 @@ class DeckGeneratorV3:
# Step 5b: Fill basic lands to hit exactly 100 cards
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
# Step 5c: Secondary safety-net deduplication
seen = {}
deduped = []
for c in cards_with_status:
key = c.name.lower()
if key in seen:
deduped[seen[key]].count += c.count
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
else:
seen[key] = len(deduped)
deduped.append(c)
cards_with_status = deduped
# Step 6: Compute stats
stats = self._compute_stats(cards_with_status)
@@ -284,15 +297,26 @@ class DeckGeneratorV3:
"""
Cross-reference generated cards against the collection DB.
Sets owned/scryfall_id/owned_qty and real price.
Deduplicates cards by name (case-insensitive) — if the LLM
returns the same card twice, the counts are merged.
"""
conn = self.db_conn_factory()
enriched = []
seen_names: dict[str, int] = {} # name_lower → index in enriched
for card in cards:
name = card.get("name", "")
if not name:
continue
name_lower = name.lower()
if name_lower in seen_names:
idx = seen_names[name_lower]
enriched[idx].count += card.get("count", 1)
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
continue
# Look up in collection
row = conn.execute(
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
@@ -318,6 +342,7 @@ class DeckGeneratorV3:
scryfall_id=row["scryfall_id"] if row else "",
status="owned" if is_owned else "missing",
)
seen_names[name_lower] = len(enriched)
enriched.append(enriched_card)
return enriched
+129
View File
@@ -0,0 +1,129 @@
# Closed-Loop Iterative Distillation — Implementation Roadmap
> **Goal:** Create a feedback loop where the PPO self-play agent exports its game decisions
> as JSONL files, which the supervised training pipeline incorporates alongside Forge
> heuristic data. The supervised baseline gradually improves as the RL agent discovers
> better play patterns — the RL agent teaches the supervised model.
---
## Phase 1 — PPO Decision Exporter
**Goal:** PPO self-play writes decisions in the same JSONL format as Forge batch sims.
- [ ] Add a `DecisionExporter` class in `ml/training/self_play.py` that captures each
(state, action, reward, game_context) during PPO rollouts
- [ ] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl` using the exact same
schema as the Forge `DecisionSnapshot` files
- [ ] Include metadata fields to distinguish PPO-generated data:
- `"source": "ppo"`
- `"model_version"`
- `"reward"`
- `"episode_return"`
- [ ] Only export decisions from **winning** games initially (higher signal-to-noise)
**Files touched:** `ml/training/self_play.py`, new `ml/training/decision_exporter.py`
---
## Phase 2 — Dataset Builder Source Awareness
**Goal:** `dataset_builder.py` can ingest both Forge and PPO decision files with
configurable mixing.
- [ ] Update `build_dataset()` to scan for both `ml-decisions-sim-*.jsonl` (Forge) and
`ml-decisions-ppo-*.jsonl` (PPO) files
- [ ] Add a `source_weights` config param — e.g., `{"forge": 1.0, "ppo": 0.5}` — to
control sampling ratios so PPO data doesn't overwhelm the Forge baseline early on
- [ ] Add a `min_reward_threshold` filter for PPO data — only include decisions from
games above a certain reward score
- [ ] Tag each sample with its source for downstream analysis
**Files touched:** `ml/data/dataset_builder.py`, `ml/config/scope.py`
---
## Phase 3 — Quality Gate & Validation
**Goal:** Prevent bad PPO data from degrading the supervised model.
- [ ] Add a validation split in `dataset_builder.py` (hold out 10-15% of Forge data as
a fixed eval set)
- [ ] After each supervised training run, evaluate on the held-out Forge set — if
accuracy drops below a threshold compared to the previous model, reject the PPO
data batch
- [ ] Track win rate of the PPO agent — only export decisions once the PPO model exceeds
a configurable win rate (e.g., >30% in 4-player pods, above the 25% random baseline)
- [ ] Log data quality metrics: action distribution entropy, reward distribution of
exported games
**Files touched:** `ml/training/trainer.py`, `ml/eval/`, new `ml/data/quality_gate.py`
---
## Phase 4 — Distillation Loop Orchestrator
**Goal:** Automate the full cycle: supervised train -> PPO self-play -> export -> retrain.
- [ ] New `ml/training/distillation_loop.py` that orchestrates:
1. Supervised train on current dataset (Forge + any existing PPO data)
2. Run N episodes of PPO self-play using the new supervised model as starting policy
3. Export winning PPO decisions to JSONL
4. Quality gate check
5. If passed, merge into dataset and go to step 1
6. If failed, discard PPO batch, adjust hyperparameters, retry
- [ ] Add `max_iterations` and `convergence_check` (stop if win rate plateaus across
iterations)
- [ ] Each iteration is a "generation" — tag data and models with generation number
**Files touched:** new `ml/training/distillation_loop.py`, `ml/config/scope.py`
---
## Phase 5 — UI Integration & Monitoring
**Goal:** Expose the distillation loop in the Commander AI Lab web UI.
- [ ] Add a "Distillation" tab or section in `ui/ai-lab.js` showing:
- Current generation number
- Per-generation win rate trend chart
- Data composition pie chart (% Forge vs % PPO per generation)
- Start / stop / pause controls for the loop
- [ ] API endpoints in `routes/ml.py`:
- `POST /api/ml/distill/start`
- `GET /api/ml/distill/status`
- `POST /api/ml/distill/stop`
- [ ] History of each generation's metrics stored as JSON for the trends view
**Files touched:** `routes/ml.py`, `ui/ai-lab.js`, `ui/ai-lab.css`
---
## Phase 6 — Advanced: ELO Tracking & Model Tournament
**Goal:** Quantify improvement across generations.
- [ ] Pit each generation's model against previous generations and the Forge heuristic
in batch sims
- [ ] Compute ELO ratings per generation
- [ ] Store in `data/elo_history.json` and visualize in the UI as a line chart
- [ ] Use ELO delta as a convergence signal — stop the loop when ELO gain per generation
drops below a threshold
**Files touched:** new `ml/eval/elo_tracker.py`, `routes/ml.py`, `ui/ai-lab.js`
---
## Priority
| Phase | Priority | Description |
|-------|----------|-------------|
| 1 | **Critical** | PPO decision export — foundation for everything |
| 2 | **Critical** | Dataset builder ingests both sources |
| 3 | **Critical** | Quality gate prevents model regression |
| 4 | High | Automation of the full loop |
| 5 | Medium | UI visibility and controls |
| 6 | Low | ELO tracking and tournament system |
> **Phases 1-3** are the core — get those working and you have a functional closed loop.
> **Phase 4** automates it. **Phases 5-6** are polish and observability.
@@ -0,0 +1,93 @@
# Phase 1 — Self-Play Integration Checklist
> **Issue:** #65 — PPO Decision Exporter
> **Ref:** `docs/CLOSED-LOOP-DISTILLATION.md`
> **Status:** In Progress
---
## Completed
- [x] Create `ml/training/decision_exporter.py` with `DecisionExporter` class
- [x] `ExporterConfig` dataclass (output_dir, only_wins, model_version, min_episode_steps)
- [x] `build_decision_record()` — converts PPO step data into Forge-compatible JSONL format
- [x] `DecisionExporter.begin_episode()` / `record_step()` / `end_episode()` / `flush()` lifecycle
- [x] Write to `data/results/ml-decisions-ppo-{batch_id}.jsonl`
- [x] Include PPO metadata fields: `source`, `model_version`, `reward`, `episode_return`
- [x] Win-only filtering (only export decisions from winning games)
- [x] Short-episode gating (`min_episode_steps` threshold)
- [x] `_macro_action_to_forge_type()` mapping (MacroAction enum → Forge action type strings)
- [x] Export stats tracking (`stats` property)
---
## Remaining — Integrate into `ml/training/self_play.py`
### Import
- [ ] Add import: `from ml.training.decision_exporter import DecisionExporter`
### Modify `run_self_play_episode()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] After each `trajectory.append(...)` block (3 locations), add:
```python
if exporter is not None:
exporter.record_step(
game_state_snapshot=prev_snapshot,
action_idx=action_idx,
reward=reward,
)
```
### Modify `collect_rollouts()`
- [ ] Add `exporter: DecisionExporter = None` parameter to function signature
- [ ] Before each episode, call:
```python
if exporter is not None:
exporter.begin_episode(agent_seat=agent_seat, playstyle=playstyle)
```
- [ ] Pass `exporter=exporter` to `run_self_play_episode()`
- [ ] After outcome tracking, call:
```python
if exporter is not None:
won = trajectory and trajectory[-1]["done"] and trajectory[-1]["reward"] > 0.5
ep_return = sum(s["reward"] for s in trajectory)
exporter.end_episode(won=won, episode_return=ep_return)
```
### Commit
- [ ] `git add ml/training/self_play.py`
- [ ] `git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"`
- [ ] `git push origin main`
---
## Integration Pattern
```
collect_rollouts(exporter=exporter)
└─ for each episode:
├─ exporter.begin_episode(seat, playstyle)
├─ run_self_play_episode(..., exporter=exporter)
│ └─ for each step:
│ ├─ trajectory.append(step_data)
│ └─ exporter.record_step(snapshot, action, reward)
└─ exporter.end_episode(won, episode_return)
# Caller does: exporter.flush()
```
---
## Terminal Commands
```bash
cd commander-ai-lab
git pull origin main
# Make edits to ml/training/self_play.py per checklist above
git add ml/training/self_play.py
git commit -m "feat(ml): integrate DecisionExporter into self-play rollouts (#65)"
git push origin main
```
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-73
View File
@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>commander-ai-lab-ui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
-35
View File
@@ -1,35 +0,0 @@
{
"name": "commander-ai-lab-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"recharts": "^3.8.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
-27
View File
@@ -1,27 +0,0 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { PageLayout } from './components/layout/PageLayout'
import { BatchSimPage } from './pages/BatchSimPage'
import { CollectionPage } from './pages/CollectionPage'
import { DecksPage } from './pages/DecksPage'
import { AutoGenPage } from './pages/AutoGenPage'
import { SimulatorPage } from './pages/SimulatorPage'
import { CoachPage } from './pages/CoachPage'
import { TrainingPage } from './pages/TrainingPage'
export default function App() {
return (
<BrowserRouter>
<PageLayout>
<Routes>
<Route path="/" element={<BatchSimPage />} />
<Route path="/collection" element={<CollectionPage />} />
<Route path="/decks" element={<DecksPage />} />
<Route path="/autogen" element={<AutoGenPage />} />
<Route path="/simulator" element={<SimulatorPage />} />
<Route path="/coach" element={<CoachPage />} />
<Route path="/training" element={<TrainingPage />} />
</Routes>
</PageLayout>
</BrowserRouter>
)
}
@@ -1,82 +0,0 @@
// ═══════════════════════════════════════════════════════════
// Base API client — all fetch calls go through here
// ═══════════════════════════════════════════════════════════
const BASE = '' // same origin; Vite proxy handles /api in dev
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function handleResponse<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text()
let message = `HTTP ${res.status}`
try {
const json = JSON.parse(body)
message = json.detail || json.error || message
} catch {
if (body) message = body
}
throw new ApiError(res.status, message)
}
// Handle 204 No Content
if (res.status === 204) return undefined as T
return res.json()
}
export async function get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const url = new URL(BASE + path, window.location.origin)
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
})
}
const res = await fetch(url.toString())
return handleResponse<T>(res)
}
export async function post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
return handleResponse<T>(res)
}
export async function postForm<T>(path: string, formData: FormData): Promise<T> {
const res = await fetch(BASE + path, {
method: 'POST',
body: formData,
})
return handleResponse<T>(res)
}
export async function put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function patch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(BASE + path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function del<T>(path: string): Promise<T> {
const res = await fetch(BASE + path, { method: 'DELETE' })
return handleResponse<T>(res)
}
@@ -1,131 +0,0 @@
import { get, post } from './client'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachChatResponse, CoachApplyResult, CoachCardLikeResult
} from '../types'
export async function getCoachStatus() {
return get<CoachStatus>('/api/coach/status')
}
export async function getCoachDecks() {
return get<CoachDeck[]>('/api/coach/decks')
}
export async function getCoachDeckReport(deckId: string) {
return get<Record<string, unknown>>(`/api/coach/decks/${deckId}/report`)
}
export async function runCoachSession(deckId: string, goals?: Record<string, unknown>) {
return post<CoachSession>(`/api/coach/decks/${deckId}`, goals ? { goals } : undefined)
}
export async function getCoachSessions(deckId?: string) {
const params: Record<string, string> = {}
if (deckId) params.deck_id = deckId
return get<{ sessions: CoachSessionSummary[] }>('/api/coach/sessions', params)
}
export async function getCoachSession(sessionId: string) {
return get<CoachSession>(`/api/coach/sessions/${sessionId}`)
}
export async function downloadEmbeddings() {
return post<{ success: boolean; cards: number }>('/api/coach/embeddings/download')
}
export async function coachChat(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>
) {
return post<CoachChatResponse>('/api/coach/chat', {
deck_id: deckId,
messages,
goals,
stream: false,
})
}
export async function coachChatStream(
deckId: string,
messages: { role: string; content: string }[],
goals?: Record<string, unknown>,
onChunk?: (text: string) => void,
signal?: AbortSignal
): Promise<string> {
const res = await fetch('/api/coach/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deck_id: deckId,
messages,
goals,
stream: true,
}),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(body || `HTTP ${res.status}`)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let fullText = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') break
try {
const parsed = JSON.parse(data)
if (parsed.content) {
fullText += parsed.content
onChunk?.(fullText)
}
} catch { /* skip bad chunks */ }
}
}
}
return fullText
}
export async function applyCoachSuggestions(
sessionId: string,
deckId: number,
acceptedCuts: string[],
acceptedAdds: string[]
) {
return post<CoachApplyResult>('/api/coach/apply', {
session_id: sessionId,
deck_id: deckId,
accepted_cuts: acceptedCuts,
accepted_adds: acceptedAdds,
})
}
export async function searchCardsLike(card: string, colors?: string, topN = 10) {
const params: Record<string, string | number> = { card, top_n: topN }
if (colors) params.colors = colors
return get<{ query: string; results: CoachCardLikeResult[] }>('/api/coach/cards-like', params)
}
export async function generateReport(deckId?: number) {
return post<{ status: string; decksUpdated: string[]; count: number }>('/api/coach/reports/generate',
deckId ? { deck_id: deckId } : undefined
)
}
@@ -1,86 +0,0 @@
import { get, post, patch, postForm } from './client'
import type { CollectionCard, CollectionFilters, SetInfo, ScanResult } from '../types'
export async function searchCollection(filters: CollectionFilters) {
// Map frontend snake_case filter names to backend camelCase param names
const params: Record<string, string | number | boolean> = {}
if (filters.q) params.q = filters.q
if (filters.page) params.page = filters.page
if (filters.page_size) params.pageSize = filters.page_size
if (filters.sort_by) params.sortField = filters.sort_by
if (filters.sort_dir) params.sortDir = filters.sort_dir
if (filters.color) params.colors = filters.color
if (filters.type_line) params.types = filters.type_line
if (filters.rarity) params.rarity = filters.rarity
if (filters.set_code) params.setCode = filters.set_code
if (filters.cmc_min !== undefined) params.cmcMin = filters.cmc_min
if (filters.cmc_max !== undefined) params.cmcMax = filters.cmc_max
if (filters.price_min !== undefined) params.priceMin = filters.price_min
if (filters.price_max !== undefined) params.priceMax = filters.price_max
if (filters.owned_only !== undefined) params.owned_only = filters.owned_only
const res = await get<{ items?: CollectionCard[]; cards?: CollectionCard[]; total: number; page: number; pageSize?: number; page_size?: number }>(
'/api/collection', params
)
return {
cards: res.items || res.cards || [],
total: res.total || 0,
page: res.page || 1,
page_size: res.pageSize || res.page_size || 48,
}
}
export async function getCard(cardId: number) {
return get<CollectionCard>(`/api/collection/${cardId}`)
}
export async function updateCard(cardId: number, updates: Partial<CollectionCard>) {
return patch<CollectionCard>(`/api/collection/${cardId}`, updates)
}
export async function getSets() {
const res = await get<SetInfo[] | { sets: SetInfo[] }>('/api/collection/sets')
return Array.isArray(res) ? res : (res as { sets: SetInfo[] }).sets || []
}
export async function getKeywords() {
const res = await get<string[] | { keywords: string[] }>('/api/collection/keywords')
return Array.isArray(res) ? res : (res as { keywords: string[] }).keywords || []
}
export async function exportCollection(format: string = 'csv') {
const res = await fetch(`/api/collection/export?format=${format}`)
return res.blob()
}
export async function importCollection(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<{ imported: number; errors: string[] }>('/api/collection/import', fd)
}
export async function scanCard(file: File) {
const fd = new FormData()
fd.append('image', file)
return postForm<ScanResult>('/api/collection/scan', fd)
}
export async function addScanResult(match: { name: string; set_code: string; scryfall_id: string; quantity?: number }) {
return post<{ success: boolean; card_id: number }>('/api/collection/scan/add', match)
}
export async function reEnrichCards() {
return post<{ updated: number }>('/api/collection/re-enrich')
}
export async function autoClassify(cardId: number) {
return post<{ classifications: string[] }>(`/api/collection/auto-classify`, { card_id: cardId })
}
export async function autoClassifyAll() {
return post<{ processed: number }>('/api/collection/auto-classify-all')
}
export async function getEdhrecData(cardId: number) {
return get<Record<string, unknown>>(`/api/collection/${cardId}/edhrec`)
}
@@ -1,22 +0,0 @@
import { get, post } from './client'
import type { DeckGenV3Request, DeckGenV3Status, DeckGenV3Result, CommanderSearchResult } from '../types'
export async function getV3Status() {
return get<DeckGenV3Status>('/api/deck/v3/status')
}
export async function searchCommanders(q: string) {
return get<CommanderSearchResult[]>('/api/deck-generator/commander-search', { q })
}
export async function generateDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result>('/api/deck/v3/generate', req)
}
export async function commitDeckV3(req: DeckGenV3Request) {
return post<DeckGenV3Result & { deck_id: number; dck_path: string }>('/api/deck/v3/commit', req)
}
export async function exportDeckV3(format: 'csv' | 'dck' | 'moxfield' | 'shopping', req: DeckGenV3Request) {
return post<Record<string, unknown>>(`/api/deck/v3/export/${format}`, req)
}
@@ -1,130 +0,0 @@
import { get, post, put, del, patch } from './client'
import type { Deck, DeckCard, DeckAnalysis, EdhRecsResponse, CollectionRecsResponse, PplxStatus } from '../types'
export async function listDecks() {
const res = await get<{ decks: Record<string, unknown>[] } | Record<string, unknown>[]>('/api/decks')
const raw = Array.isArray(res) ? res : (res as { decks: Record<string, unknown>[] }).decks || []
return raw.map(d => ({
...d,
commander: d.commander || d.commander_name || '',
card_count: d.card_count ?? d.total_cards ?? 0,
total_price: d.total_price ?? 0,
color_identity: d.color_identity || [],
created_date: d.created_date || d.created_at || '',
updated_date: d.updated_date || d.updated_at || '',
})) as Deck[]
}
export async function getDeck(deckId: number) {
return get<Deck>(`/api/decks/${deckId}`)
}
export async function createDeck(deck: { name: string; commander?: string; format?: string }) {
return post<Deck>('/api/decks', deck)
}
export async function updateDeck(deckId: number, updates: Partial<Deck>) {
return put<Deck>(`/api/decks/${deckId}`, updates)
}
export async function deleteDeck(deckId: number) {
return del<void>(`/api/decks/${deckId}`)
}
export async function deleteAllDecks() {
return del<void>('/api/decks')
}
export async function getDeckCards(deckId: number) {
const res = await get<{ cards: Record<string, unknown>[]; total: number } | Record<string, unknown>[]>(`/api/decks/${deckId}/cards`)
const raw = Array.isArray(res) ? res : (res as { cards: Record<string, unknown>[] }).cards || []
return raw.map(c => ({
...c,
name: c.name || c.card_name || '',
category: c.category || c.role_tag || '',
})) as DeckCard[]
}
export async function addCardToDeck(deckId: number, card: { name: string; scryfall_id?: string; quantity?: number; category?: string }) {
return post<DeckCard>(`/api/decks/${deckId}/cards`, card)
}
export async function removeCardFromDeck(deckId: number, cardId: number) {
return del<void>(`/api/decks/${deckId}/cards/${cardId}`)
}
export async function updateDeckCard(deckId: number, cardId: number, updates: Partial<DeckCard>) {
return patch<DeckCard>(`/api/decks/${deckId}/cards/${cardId}`, updates)
}
export async function getDeckAnalysis(deckId: number) {
const raw = await get<Record<string, unknown>>(`/api/decks/${deckId}/analysis`)
// Backend returns: counts_by_type, targets, deltas, mana_curve, color_pips, total_cards, roles
// Frontend DeckAnalysis expects: card_count, land_count, creature_count, noncreature_count, avg_cmc, mana_curve, color_distribution, type_distribution, total_price, owned_count, missing_count
const counts = (raw.counts_by_type || {}) as Record<string, number>
const totalCards = (raw.total_cards as number) || 0
const landCount = counts['Land'] || 0
const creatureCount = counts['Creature'] || 0
const manaCurve = (raw.mana_curve || {}) as Record<string | number, number>
const colorPips = (raw.color_pips || {}) as Record<string, number>
// Compute avg CMC from mana curve
let totalMana = 0, nonLandCards = 0
for (const [cmc, count] of Object.entries(manaCurve)) {
const cmcNum = cmc === '6+' ? 6 : Number(cmc)
totalMana += cmcNum * count
nonLandCards += count
}
return {
card_count: totalCards,
land_count: landCount,
creature_count: creatureCount,
noncreature_count: totalCards - landCount - creatureCount,
avg_cmc: nonLandCards > 0 ? totalMana / nonLandCards : 0,
mana_curve: manaCurve as Record<number, number>,
color_distribution: colorPips,
type_distribution: counts,
total_price: 0,
owned_count: totalCards,
missing_count: 0,
} as DeckAnalysis
}
export async function getCollectionRecs(deckId: number) {
return get<CollectionRecsResponse>(`/api/decks/${deckId}/recommended-from-collection`)
}
export async function getEdhRecs(deckId: number, onlyOwned = false, maxResults = 30) {
return get<EdhRecsResponse>(`/api/decks/${deckId}/edh-recs?only_owned=${onlyOwned}&max_results=${maxResults}`)
}
export async function bulkAddCards(deckId: number, cards: { name: string; quantity?: number }[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add`, { cards })
}
export async function bulkAddRecommended(deckId: number, cards: string[]) {
return post<{ added: number }>(`/api/decks/${deckId}/bulk-add-recommended`, { card_names: cards })
}
export async function exportDeckToSim(deckId: number) {
return post<{ path: string }>(`/api/decks/${deckId}/export-to-sim`)
}
export async function importDeckToDeck(deckId: number, data: { text?: string; url?: string }) {
return post<{ imported: number }>(`/api/decks/${deckId}/import`, data)
}
export async function importNewDeck(data: { text?: string; url?: string; name?: string }) {
return post<Deck>('/api/decks/import-new', data)
}
export async function getPplxStatus() {
return get<PplxStatus>('/api/pplx/status')
}
export async function deckResearch(payload: { commander: string; strategy?: string }) {
return post<{ research: string }>('/api/deck-research', payload)
}
export async function deckGenerate(payload: { commander: string; strategy?: string; bracket?: number }) {
return post<{ cards: Array<{ name: string; quantity: number; category: string }> }>('/api/deck-generate', payload)
}
@@ -1,7 +0,0 @@
export * as collectionApi from './collection'
export * as decksApi from './decks'
export * as deckgenApi from './deckgen'
export * as labApi from './lab'
export * as simApi from './sim'
export * as coachApi from './coach'
export * as trainingApi from './training'
@@ -1,65 +0,0 @@
import { get, post } from './client'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
export async function getLabDecks() {
const res = await get<{ decks: LabDeck[] } | LabDeck[]>('/api/lab/decks')
return Array.isArray(res) ? res : (res as { decks: LabDeck[] }).decks || []
}
export async function startBatchSim(config: { decks: string[]; games: number; threads?: number; use_deepseek?: boolean; deepseek_deck?: string }) {
return post<{ run_id: string }>('/api/lab/start', config)
}
export async function startDeepSeekSim(config: { decks: string[]; games: number; deepseek_deck: string }) {
return post<{ run_id: string }>('/api/lab/start-deepseek', config)
}
export async function getLabStatus() {
return get<LabStatus>('/api/lab/status')
}
export async function getLabResult() {
return get<LabResult>('/api/lab/result')
}
export async function getLabHistory() {
const res = await get<{ results: LabHistoryEntry[] } | LabHistoryEntry[]>('/api/lab/history')
return Array.isArray(res) ? res : (res as { results: LabHistoryEntry[] }).results || []
}
export async function getPrecons() {
const res = await get<{ precons: PreconDeck[] } | PreconDeck[]>('/api/lab/precons')
return Array.isArray(res) ? res : (res as { precons: PreconDeck[] }).precons || []
}
export async function installPrecon(name: string) {
return post<{ installed: boolean }>('/api/lab/precons/install', { name })
}
export async function installPreconBatch(names: string[]) {
return post<{ installed: number }>('/api/lab/precons/install-batch', { names })
}
export async function getMetaCommanders() {
return get<Array<{ name: string; color_identity: string[] }>>('/api/lab/meta/commanders')
}
export async function searchMetaCommanders(q: string) {
return get<Array<{ name: string; color_identity: string[]; source: string }>>('/api/lab/meta/search', { q })
}
export async function fetchMetaDeck(commander: string) {
return post<{ deck_name: string; cards: Array<{ name: string; quantity: number }> }>('/api/lab/meta/fetch', { commander })
}
export async function importDeckFromUrl(url: string) {
return post<{ deck_name: string }>('/api/lab/import/url', { url })
}
export async function importDeckFromText(text: string, name?: string) {
return post<{ deck_name: string }>('/api/lab/import/text', { text, name })
}
export async function getLabLog() {
return get<{ log: string }>('/api/lab/log')
}
@@ -1,38 +0,0 @@
import { get, post } from './client'
import type { SimStatus, SimResult, DeepSeekStatus } from '../types'
export async function runSim(config: { deck_a: string; deck_b: string; games: number }) {
return post<{ sim_id: string }>('/api/sim/run', config)
}
export async function runSimFromDeck(deckId: number, config: { opponent?: string; games?: number }) {
return post<{ sim_id: string }>('/api/sim/run-from-deck', { deck_id: deckId, ...config })
}
export async function runDeepSeekSim(config: { deck_a: string; deck_b: string; games: number; deepseek_deck: string }) {
return post<{ sim_id: string }>('/api/sim/run-deepseek', config)
}
export async function getSimStatus(simId: string) {
return get<SimStatus>('/api/sim/status', { simId })
}
export async function getSimResult(simId: string) {
return get<SimResult>('/api/sim/result', { simId })
}
export async function connectDeepSeek() {
return post<DeepSeekStatus>('/api/deepseek/connect')
}
export async function getDeepSeekStatus() {
return get<DeepSeekStatus>('/api/deepseek/status')
}
export async function configureDeepSeek(config: { model?: string; base_url?: string }) {
return post<DeepSeekStatus>('/api/deepseek/configure', config)
}
export async function getDeepSeekLogs() {
return get<Array<{ filename: string; size: number; modified: string }>>('/api/deepseek/logs')
}
@@ -1,92 +0,0 @@
import { get, post } from './client'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, TournamentResults, MLModelInfo
} from '../types'
// ── ML Status & Toggle ──────────────────────────────────────
export async function getMLStatus() {
return get<MLStatus>('/api/ml/status')
}
export async function toggleML(enable = true) {
return post<{ ml_logging_enabled: boolean; message: string }>(`/api/ml/toggle?enable=${enable}`)
}
// ── Data & Model Info ───────────────────────────────────────
export async function getDataStatus() {
return get<MLDataStatus>('/api/ml/data/status')
}
export async function getModelInfo() {
return get<MLModelInfo>('/api/ml/model')
}
export async function reloadModel(checkpoint?: string) {
return post<{ success: boolean; status: MLModelInfo }>('/api/ml/reload', checkpoint ? { checkpoint } : undefined)
}
// ── Supervised Training ─────────────────────────────────────
export async function startTraining(config: {
epochs?: number
lr?: number
batchSize?: number
patience?: number
rebuildDataset?: boolean
}) {
return post<{ status: string; config: Record<string, unknown> }>('/api/ml/train', config)
}
export async function getTrainStatus() {
return get<MLTrainStatus>('/api/ml/train/status')
}
// ── PPO Training ────────────────────────────────────────────
export async function startPPOTraining(config: {
iterations?: number
episodesPerIter?: number
ppoEpochs?: number
batchSize?: number
lr?: number
clipEpsilon?: number
entropyCoeff?: number
opponent?: string
playstyle?: string
loadSupervised?: string
}) {
return post<{ status: string; iterations: number }>('/api/ml/train/ppo', config)
}
export async function getPPOStatus() {
return get<PPOTrainStatus>('/api/ml/train/ppo/status')
}
// ── Tournament ──────────────────────────────────────────────
export async function startTournament(config: {
episodes?: number
playstyle?: string
}) {
return post<{ status: string; episodes: number }>('/api/ml/tournament', config)
}
export async function getTournamentStatus() {
return get<TournamentStatus>('/api/ml/tournament/status')
}
export async function getTournamentResults() {
return get<TournamentResults>('/api/ml/tournament/results')
}
// ── Decision Files ──────────────────────────────────────────
export async function getDecisionFile(filename: string, limit = 100, offset = 0) {
return get<{ file: string; offset: number; limit: number; count: number; decisions: Record<string, unknown>[] }>(
`/api/ml/decisions/${filename}?limit=${limit}&offset=${offset}`
)
}
// ── Prediction ──────────────────────────────────────────────
export async function predict(state: Record<string, unknown>) {
return post<{ action: string; action_index: number; confidence: number; probabilities: Record<string, number>; inference_ms: number }>(
'/api/ml/predict', state
)
}
@@ -1,49 +0,0 @@
import { useState } from 'react'
interface CardImageProps {
src: string
alt: string
size?: 'sm' | 'md' | 'lg' | 'xl'
className?: string
onClick?: () => void
}
const SIZES = {
sm: 'w-16 h-[22px]', // tiny inline
md: 'w-[146px] h-[204px]', // grid thumbnail
lg: 'w-[244px] h-[340px]', // detail view
xl: 'w-[336px] h-[468px]', // full size
}
export function CardImage({ src, alt, size = 'md', className = '', onClick }: CardImageProps) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
// Build Scryfall URL if needed — ensure we get the right size
const imgUrl = src || ''
return (
<div
className={`relative overflow-hidden rounded-lg bg-bg-tertiary ${SIZES[size]} ${onClick ? 'cursor-pointer hover:ring-2 hover:ring-accent-blue/50 transition-all' : ''} ${className}`}
onClick={onClick}
>
{!error ? (
<img
src={imgUrl}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLoaded(true)}
onError={() => setError(true)}
loading="lazy"
/>
) : (
<div className="flex items-center justify-center w-full h-full text-text-tertiary text-xs text-center p-2">
{alt}
</div>
)}
{!loaded && !error && (
<div className="absolute inset-0 bg-bg-tertiary animate-pulse" />
)}
</div>
)
}
@@ -1,19 +0,0 @@
import type { LucideIcon } from 'lucide-react'
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: string
action?: React.ReactNode
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Icon className="w-12 h-12 text-text-tertiary mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-semibold text-text-primary mb-1">{title}</h3>
{description && <p className="text-sm text-text-secondary max-w-md mb-4">{description}</p>}
{action}
</div>
)
}
@@ -1,69 +0,0 @@
// Parse MTG mana cost strings like "{2}{W}{U}" into colored symbols
const MANA_COLORS: Record<string, string> = {
W: 'bg-mana-white text-black',
U: 'bg-mana-blue text-white',
B: 'bg-mana-black text-white',
R: 'bg-mana-red text-white',
G: 'bg-mana-green text-white',
C: 'bg-mana-colorless text-white',
}
interface ManaSymbolsProps {
cost: string
size?: 'sm' | 'md'
}
export function ManaSymbols({ cost, size = 'sm' }: ManaSymbolsProps) {
if (!cost) return null
const symbols = cost.match(/\{([^}]+)\}/g)
if (!symbols) return <span className="text-text-tertiary">{cost}</span>
const sz = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs'
return (
<span className="inline-flex items-center gap-0.5">
{symbols.map((s, i) => {
const val = s.replace(/[{}]/g, '')
const colorClass = MANA_COLORS[val] || 'bg-mana-colorless text-white'
return (
<span
key={i}
className={`${sz} inline-flex items-center justify-center rounded-full font-bold ${colorClass}`}
>
{val}
</span>
)
})}
</span>
)
}
// Color identity dots
interface ColorDotsProps {
colors: string[]
size?: 'sm' | 'md'
}
export function ColorDots({ colors, size = 'sm' }: ColorDotsProps) {
if (!colors || colors.length === 0) return <span className="text-text-tertiary text-xs">Colorless</span>
const sz = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
const DOT_COLORS: Record<string, string> = {
W: 'bg-mana-white',
U: 'bg-mana-blue',
B: 'bg-mana-black border border-border-secondary',
R: 'bg-mana-red',
G: 'bg-mana-green',
}
return (
<span className="inline-flex items-center gap-0.5">
{colors.map(c => (
<span key={c} className={`${sz} rounded-full ${DOT_COLORS[c] || 'bg-mana-colorless'}`} />
))}
</span>
)
}
@@ -1,38 +0,0 @@
interface SpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
const SIZES = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
return (
<svg
className={`animate-spin ${SIZES[size]} ${className}`}
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12" cy="12" r="10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
className="opacity-20"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
)
}
export function PageSpinner() {
return (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
@@ -1,35 +0,0 @@
import { CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
type Variant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
interface StatusBadgeProps {
variant: Variant
label: string
className?: string
}
const STYLES: Record<Variant, string> = {
success: 'bg-status-success/15 text-status-success border-status-success/30',
error: 'bg-status-error/15 text-status-error border-status-error/30',
warning: 'bg-status-warning/15 text-status-warning border-status-warning/30',
info: 'bg-status-info/15 text-status-info border-status-info/30',
neutral: 'bg-bg-tertiary text-text-secondary border-border-primary',
}
const ICONS: Record<Variant, typeof CheckCircle> = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
neutral: Info,
}
export function StatusBadge({ variant, label, className = '' }: StatusBadgeProps) {
const Icon = ICONS[variant]
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md border ${STYLES[variant]} ${className}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
)
}
@@ -1,5 +0,0 @@
export { Spinner, PageSpinner } from './Spinner'
export { StatusBadge } from './StatusBadge'
export { CardImage } from './CardImage'
export { ManaSymbols, ColorDots } from './ManaSymbols'
export { EmptyState } from './EmptyState'
@@ -1,16 +0,0 @@
import { Sidebar } from './Sidebar'
interface PageLayoutProps {
children: React.ReactNode
}
export function PageLayout({ children }: PageLayoutProps) {
return (
<div className="min-h-screen bg-bg-primary">
<Sidebar />
<main className="ml-56 min-h-screen">
{children}
</main>
</div>
)
}
@@ -1,51 +0,0 @@
import { NavLink } from 'react-router-dom'
const NAV_ITEMS = [
{ to: '/', label: 'Batch Sim', icon: '⚗️' },
{ to: '/collection', label: 'Collection', icon: '📦' },
{ to: '/decks', label: 'Decks', icon: '🃏' },
{ to: '/autogen', label: 'Auto Gen', icon: '⚡' },
{ to: '/simulator', label: 'Simulator', icon: '🎮' },
{ to: '/coach', label: 'Coach', icon: '🧠' },
{ to: '/training', label: 'Training', icon: '📈' },
]
export function Sidebar() {
return (
<aside className="fixed left-0 top-0 bottom-0 w-56 bg-bg-secondary border-r border-border-primary flex flex-col z-30">
{/* Logo */}
<div className="px-5 py-5 border-b border-border-primary">
<h1 className="text-base font-bold text-text-primary tracking-tight">
Commander AI Lab
</h1>
<p className="text-xs text-text-tertiary mt-0.5">MTG Intelligence Platform</p>
</div>
{/* Nav */}
<nav className="flex-1 py-3 overflow-y-auto">
{NAV_ITEMS.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors duration-150 ${
isActive
? 'bg-accent-blue/10 text-accent-blue border-r-2 border-accent-blue'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-hover'
}`
}
>
<span className="text-base">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
{/* Footer */}
<div className="px-5 py-4 border-t border-border-primary">
<p className="text-xs text-text-tertiary">v4.0 — React + TypeScript</p>
</div>
</aside>
)
}
@@ -1,13 +0,0 @@
import { useEffect, useRef } from 'react'
/** Polls a function at the given interval while `enabled` is true */
export function usePolling(fn: () => void, intervalMs: number, enabled: boolean) {
const savedFn = useRef(fn)
savedFn.current = fn
useEffect(() => {
if (!enabled) return
const id = setInterval(() => savedFn.current(), intervalMs)
return () => clearInterval(id)
}, [intervalMs, enabled])
}
-114
View File
@@ -1,114 +0,0 @@
@import "tailwindcss";
/* ═══════════════════════════════════════════════════════════
Commander AI Lab — Design Tokens (Tailwind v4 CSS Theme)
Fresh dark theme with MTG-inspired accents
═══════════════════════════════════════════════════════════ */
@theme {
/* ── Backgrounds ── */
--color-bg-primary: #0f1117;
--color-bg-secondary: #161921;
--color-bg-tertiary: #1c2030;
--color-bg-elevated: #222738;
--color-bg-hover: #2a3048;
/* ── Borders ── */
--color-border-primary: #2a2f42;
--color-border-secondary: #363c54;
--color-border-accent: #4f6ef7;
/* ── Text ── */
--color-text-primary: #e8eaf0;
--color-text-secondary: #9ba1b8;
--color-text-tertiary: #6b7190;
--color-text-inverse: #0f1117;
/* ── Accents ── */
--color-accent-blue: #4f6ef7;
--color-accent-blue-hover: #6b84ff;
--color-accent-teal: #2dd4bf;
--color-accent-teal-hover: #5eead4;
--color-accent-purple: #a78bfa;
--color-accent-amber: #fbbf24;
--color-accent-red: #f87171;
--color-accent-green: #4ade80;
/* ── MTG Mana Colors ── */
--color-mana-white: #f9faf4;
--color-mana-blue: #0e67ab;
--color-mana-black: #2b2424;
--color-mana-red: #d3202a;
--color-mana-green: #00733e;
--color-mana-colorless: #706f6f;
/* ── Status ── */
--color-status-success: #4ade80;
--color-status-warning: #fbbf24;
--color-status-error: #f87171;
--color-status-info: #60a5fa;
/* ── Radius ── */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* ── Shadows ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow-blue: 0 0 20px rgba(79, 110, 247, 0.15);
--shadow-glow-teal: 0 0 20px rgba(45, 212, 191, 0.15);
}
/* ── Base Styles ── */
html {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* ── Utility animations ── */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(79, 110, 247, 0.2); }
50% { box-shadow: 0 0 16px 4px rgba(79, 110, 247, 0.15); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-pulse-glow {
animation: pulse-glow 2s infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -1,436 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import {
Search, Wand2, Save, AlertCircle,
ChevronDown, Sparkles, RefreshCw, Check
} from 'lucide-react'
import { Spinner, StatusBadge, ManaSymbols, CardImage, ColorDots } from '../components/common'
import { deckgenApi } from '../api'
import type { DeckGenV3Status, DeckGenV3Result, DeckGenV3Card, CommanderSearchResult } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Commander Search ──────────────────────────────────────────
function CommanderSearch({ onSelect }: { onSelect: (c: CommanderSearchResult) => void }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CommanderSearchResult[]>([])
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
function handleSearch(q: string) {
setQuery(q)
clearTimeout(debounceRef.current)
if (q.length < 2) { setResults([]); return }
debounceRef.current = setTimeout(async () => {
setSearching(true)
try {
const r = await deckgenApi.searchCommanders(q)
setResults(r)
} catch { setResults([]) }
setSearching(false)
}, 300)
}
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search for a commander..."
value={query}
onChange={e => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
{searching && <Spinner size="sm" className="absolute right-3 top-1/2 -translate-y-1/2 text-accent-blue" />}
</div>
{results.length > 0 && (
<div className="absolute z-20 top-full mt-1 w-full bg-bg-elevated border border-border-primary rounded-xl shadow-lg max-h-80 overflow-y-auto">
{results.map(c => (
<button
key={c.scryfall_id}
onClick={() => { onSelect(c); setQuery(c.name); setResults([]) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-bg-hover text-left transition-colors"
>
<CardImage src={c.image_url} alt={c.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{c.name}</p>
<p className="text-xs text-text-secondary truncate">{c.type_line}</p>
</div>
<ColorDots colors={c.color_identity} />
{c.in_collection && (
<span className="text-[10px] px-1.5 py-0.5 bg-accent-green/15 text-accent-green rounded">Owned</span>
)}
</button>
))}
</div>
)}
</div>
)
}
// ── Generated Deck View ───────────────────────────────────────
function GeneratedDeckView({ result, onCommit }: { result: DeckGenV3Result; onCommit: () => void }) {
const [view, setView] = useState<'visual' | 'list'>('visual')
const [committing, setCommitting] = useState(false)
const [selectedCard, setSelectedCard] = useState<DeckGenV3Card | null>(null)
// Group by category
const grouped = result.cards.reduce<Record<string, DeckGenV3Card[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const curveData = Object.entries(result.stats.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
async function handleCommit() {
setCommitting(true)
try { onCommit() } catch { /* ignore */ }
setCommitting(false)
}
return (
<div className="space-y-5 animate-fade-in">
{/* Commander header */}
<div className="flex gap-5 bg-bg-secondary rounded-xl border border-border-primary p-5">
<CardImage src={result.commander.image_url} alt={result.commander.name} size="lg" />
<div className="flex-1 space-y-3">
<div>
<h2 className="text-xl font-bold text-text-primary">{result.commander.name}</h2>
<p className="text-sm text-text-secondary">{result.commander.type_line}</p>
<div className="flex items-center gap-3 mt-2">
<ManaSymbols cost={result.commander.mana_cost} size="md" />
<ColorDots colors={result.commander.color_identity} size="md" />
</div>
</div>
<p className="text-sm text-text-primary whitespace-pre-wrap">{result.commander.oracle_text}</p>
<div className="flex items-center gap-4 text-xs text-text-secondary">
<span>Strategy: <span className="text-accent-blue font-medium">{result.strategy}</span></span>
<span>Bracket: <span className="text-accent-amber font-medium">{result.bracket}</span></span>
<span>Model: <span className="text-accent-purple font-medium">{result.model}</span></span>
</div>
{result.substitution && (
<div className="flex items-center gap-3 text-xs">
<span className="text-accent-green">{result.substitution.owned} owned</span>
<span className="text-accent-amber">{result.substitution.substituted} substituted</span>
<span className="text-accent-red">{result.substitution.missing} missing</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button onClick={handleCommit} disabled={committing}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-green/15 text-accent-green text-sm font-medium rounded-lg hover:bg-accent-green/25 border border-accent-green/30 transition-colors disabled:opacity-50">
{committing ? <Spinner size="sm" /> : <Save className="w-4 h-4" />}
Save to Decks
</button>
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button onClick={() => setView('visual')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'visual' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
Visual
</button>
<button onClick={() => setView('list')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${view === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}>
List
</button>
</div>
{/* Stats summary */}
<div className="flex-1" />
<span className="text-xs text-text-tertiary">{result.stats.card_count} cards</span>
<span className="text-xs text-accent-teal font-medium">${result.stats.total_price.toFixed(2)}</span>
</div>
{/* Mana curve mini */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">Mana Curve</h4>
<div className="h-28">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={curveData} barSize={20}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 10 }} />
<YAxis hide />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 11 }} />
<Bar dataKey="count" radius={[3, 3, 0, 0]}>
{curveData.map((_, i) => <Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Card grid/list */}
{view === 'visual' ? (
<div className="space-y-5">
{Object.entries(grouped).map(([cat, cards]) => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-3">{cat} ({cards.length})</h4>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 gap-2">
{cards.map(card => (
<div key={card.name} className="group cursor-pointer relative" onClick={() => setSelectedCard(card)}>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
{card.is_substitute && (
<div className="absolute top-1 right-1 w-4 h-4 bg-accent-amber rounded-full flex items-center justify-center">
<RefreshCw className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
{card.owned && (
<div className="absolute top-1 left-1 w-4 h-4 bg-accent-green rounded-full flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-bg-primary" />
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Category</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Role</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.cards.map(card => (
<tr key={card.name} className="hover:bg-bg-hover transition-colors">
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.quantity}x {card.name}</span>
{card.is_substitute && (
<span className="ml-2 text-[10px] text-accent-amber">sub for {card.original_name}</span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.category}</td>
<td className="px-4 py-2.5 text-xs text-text-secondary">{card.role}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-right text-accent-teal text-xs">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
<td className="px-4 py-2.5">
<span className={`text-[10px] px-2 py-0.5 rounded ${card.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary'}`}>
{card.owned ? `Owned (${card.owned_qty})` : 'Missing'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Card detail popup */}
{selectedCard && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setSelectedCard(null)}>
<div className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-2xl w-full mx-4 p-6 animate-fade-in" onClick={e => e.stopPropagation()}>
<div className="flex gap-5">
<CardImage src={selectedCard.image_url} alt={selectedCard.name} size="lg" />
<div className="flex-1 space-y-3">
<h3 className="text-lg font-bold text-text-primary">{selectedCard.name}</h3>
<p className="text-sm text-text-secondary">{selectedCard.type_line}</p>
<ManaSymbols cost={selectedCard.mana_cost} size="md" />
<p className="text-sm text-text-primary whitespace-pre-wrap">{selectedCard.reason}</p>
<div className="flex items-center gap-3 text-xs">
<span className="text-text-tertiary">Role: <span className="text-accent-blue">{selectedCard.role}</span></span>
{selectedCard.is_substitute && (
<span className="text-accent-amber">Substituted for {selectedCard.original_name} ({(selectedCard.similarity_score ?? 0 * 100).toFixed(0)}%)</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
// ── Main Auto Gen Page ────────────────────────────────────────
export function AutoGenPage() {
const [status, setStatus] = useState<DeckGenV3Status | null>(null)
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [result, setResult] = useState<DeckGenV3Result | null>(null)
const [commander, setCommander] = useState<CommanderSearchResult | null>(null)
const [strategy, setStrategy] = useState('')
const [bracket, setBracket] = useState(3)
const [budget, setBudget] = useState<string>('')
const [useCollection, setUseCollection] = useState(true)
const [runSub, setRunSub] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
deckgenApi.getV3Status().then(s => { setStatus(s); setLoading(false) }).catch(() => setLoading(false))
}, [])
async function handleGenerate() {
if (!commander) return
setGenerating(true)
setError(null)
setResult(null)
try {
const res = await deckgenApi.generateDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
setResult(res)
} catch (e) {
setError(e instanceof Error ? e.message : 'Generation failed')
}
setGenerating(false)
}
async function handleCommit() {
if (!commander || !result) return
try {
await deckgenApi.commitDeckV3({
commander_name: commander.name,
strategy: strategy || undefined,
target_bracket: bracket,
budget_usd: budget ? Number(budget) : undefined,
use_collection: useCollection,
run_substitution: runSub,
})
} catch { /* ignore */ }
}
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Sparkles className="w-6 h-6 text-accent-purple" />
Auto Gen V3
</h1>
<p className="text-sm text-text-secondary mt-0.5">AI-powered deck generation with Perplexity</p>
</div>
{/* Status bar */}
<div className="flex items-center gap-3">
<StatusBadge
variant={status?.initialized ? 'success' : 'error'}
label={status?.initialized ? 'Ready' : 'Not Initialized'}
/>
<StatusBadge
variant={status?.pplx_configured ? 'success' : 'warning'}
label={status?.pplx_configured ? 'Perplexity Connected' : 'Perplexity Not Set'}
/>
{status?.embeddings_loaded && (
<StatusBadge variant="info" label={`${status.embedding_cards.toLocaleString()} embeddings`} />
)}
</div>
{!result ? (
/* Config form */
<div className="max-w-2xl space-y-5">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Commander</label>
<CommanderSearch onSelect={setCommander} />
{commander && (
<div className="mt-2 flex items-center gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<CardImage src={commander.image_url} alt={commander.name} size="sm" />
<div>
<p className="text-sm font-medium text-text-primary">{commander.name}</p>
<p className="text-xs text-text-secondary">{commander.type_line}</p>
</div>
<ColorDots colors={commander.color_identity} />
</div>
)}
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Strategy (optional)</label>
<input
type="text"
placeholder="e.g. Voltron, Aristocrats, Storm..."
value={strategy}
onChange={e => setStrategy(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Target Bracket</label>
<select
value={bracket}
onChange={e => setBracket(Number(e.target.value))}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value={1}>1 — Casual</option>
<option value={2}>2 — Low Power</option>
<option value={3}>3 — Mid Power</option>
<option value={4}>4 — High Power</option>
</select>
</div>
<div>
<label className="text-sm font-medium text-text-primary block mb-2">Budget (USD, optional)</label>
<input
type="number"
placeholder="No limit"
value={budget}
onChange={e => setBudget(e.target.value)}
className="w-full px-4 py-3 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={useCollection} onChange={e => setUseCollection(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Use collection cards</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={runSub} onChange={e => setRunSub(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30" />
<span className="text-sm text-text-secondary">Run substitution pass</span>
</label>
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-3 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<button
onClick={handleGenerate}
disabled={!commander || generating || !status?.initialized}
className="flex items-center gap-2 px-6 py-3 bg-accent-blue text-white text-sm font-semibold rounded-lg hover:bg-accent-blue-hover shadow-glow-blue transition-all disabled:opacity-50 disabled:shadow-none"
>
{generating ? (
<><Spinner size="sm" /> Generating...</>
) : (
<><Wand2 className="w-4 h-4" /> Generate Deck</>
)}
</button>
</div>
) : (
/* Results */
<div>
<button onClick={() => setResult(null)}
className="flex items-center gap-2 mb-4 text-sm text-text-secondary hover:text-text-primary transition-colors">
<ChevronDown className="w-4 h-4 rotate-90" />
Back to Config
</button>
<GeneratedDeckView result={result} onCommit={handleCommit} />
</div>
)}
</div>
)
}
@@ -1,415 +0,0 @@
import { useState, useEffect } from 'react'
import {
FlaskConical, Play, Clock, Trophy,
Upload, Link, FileText, History, ChevronRight, AlertCircle,
Check
} from 'lucide-react'
import { Spinner } from '../components/common'
import { labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { LabDeck, LabStatus, LabResult, LabHistoryEntry, PreconDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Results Chart ─────────────────────────────────────────────
function ResultsChart({ result }: { result: LabResult }) {
const data = result.decks
.sort((a, b) => b.win_rate - a.win_rate)
.map(d => ({
name: d.name.length > 20 ? d.name.slice(0, 18) + '...' : d.name,
win_rate: Math.round(d.win_rate * 100),
fullName: d.name,
}))
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" barSize={20} margin={{ left: 10, right: 20 }}>
<XAxis type="number" domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" width={150} tick={{ fill: '#9ba1b8', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']}
labelFormatter={(label: unknown) => data.find(d => d.name === String(label))?.fullName || String(label)}
/>
<Bar dataKey="win_rate" radius={[0, 4, 4, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={i === 0 ? '#4ade80' : i === 1 ? '#4f6ef7' : '#363c54'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── Deck Selector ─────────────────────────────────────────────
function DeckSelector({
decks,
selected,
onToggle,
precons,
onInstallPrecon,
}: {
decks: LabDeck[]
selected: Set<string>
onToggle: (name: string) => void
precons: PreconDeck[]
onInstallPrecon: (name: string) => void
}) {
const [showPrecons, setShowPrecons] = useState(false)
const [installing, setInstalling] = useState<string | null>(null)
async function handleInstall(name: string) {
setInstalling(name)
await onInstallPrecon(name)
setInstalling(null)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-text-primary">Available Decks</h3>
{decks.length === 0 ? (
<p className="text-xs text-text-tertiary">No decks available. Import or install precons.</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{decks.map(d => (
<label key={d.name} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover cursor-pointer transition-colors">
<input
type="checkbox"
checked={selected.has(d.name)}
onChange={() => onToggle(d.name)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-text-primary truncate block">{d.name}</span>
<span className="text-[10px] text-text-tertiary">{d.source} · {d.card_count} cards</span>
</div>
</label>
))}
</div>
)}
<button
onClick={() => setShowPrecons(!showPrecons)}
className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-bg-tertiary rounded-lg text-text-secondary hover:bg-bg-hover border border-border-primary transition-colors"
>
Precon Decks
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showPrecons ? 'rotate-90' : ''}`} />
</button>
{showPrecons && (
<div className="space-y-1 max-h-48 overflow-y-auto animate-fade-in">
{precons.map(p => (
<div key={p.name} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-bg-tertiary">
<span className="flex-1 text-xs text-text-secondary truncate">{p.name}</span>
{p.installed ? (
<span className="text-[10px] text-accent-green flex items-center gap-1"><Check className="w-3 h-3" /> Installed</span>
) : (
<button
onClick={() => handleInstall(p.name)}
disabled={installing === p.name}
className="text-[10px] text-accent-blue hover:text-accent-blue-hover transition-colors"
>
{installing === p.name ? <Spinner size="sm" /> : 'Install'}
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
// ── Import Panel ──────────────────────────────────────────────
function ImportPanel({ onImported }: { onImported: () => void }) {
const [importUrl, setImportUrl] = useState('')
const [importText, setImportText] = useState('')
const [importName, setImportName] = useState('')
const [importing, setImporting] = useState(false)
const [importMode, setImportMode] = useState<'url' | 'text'>('url')
async function handleImport() {
setImporting(true)
try {
if (importMode === 'url' && importUrl.trim()) {
await labApi.importDeckFromUrl(importUrl.trim())
} else if (importMode === 'text' && importText.trim()) {
await labApi.importDeckFromText(importText.trim(), importName || undefined)
}
setImportUrl('')
setImportText('')
setImportName('')
onImported()
} catch { /* ignore */ }
setImporting(false)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-4 h-4 text-accent-blue" />
Import Deck
</h3>
<div className="flex gap-1">
<button onClick={() => setImportMode('url')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'url' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<Link className="w-3 h-3 inline mr-1" />URL
</button>
<button onClick={() => setImportMode('text')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${importMode === 'text' ? 'bg-accent-blue/15 text-accent-blue' : 'bg-bg-tertiary text-text-tertiary'}`}>
<FileText className="w-3 h-3 inline mr-1" />Text
</button>
</div>
{importMode === 'url' ? (
<input
type="text"
placeholder="Moxfield, Archidekt, or deck URL..."
value={importUrl}
onChange={e => setImportUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
) : (
<>
<input
type="text"
placeholder="Deck name"
value={importName}
onChange={e => setImportName(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n..."}
value={importText}
onChange={e => setImportText(e.target.value)}
rows={5}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
</>
)}
<button onClick={handleImport} disabled={importing}
className="w-full px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : 'Import'}
</button>
</div>
)
}
// ── Main Batch Sim Page ───────────────────────────────────────
export function BatchSimPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [precons, setPrecons] = useState<PreconDeck[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [games, setGames] = useState(100)
const [status, setStatus] = useState<LabStatus | null>(null)
const [result, setResult] = useState<LabResult | null>(null)
const [history, setHistory] = useState<LabHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [starting, setStarting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showHistory, setShowHistory] = useState(false)
async function fetchDecks() {
try {
const [d, p] = await Promise.all([labApi.getLabDecks(), labApi.getPrecons()])
setDecks(d)
setPrecons(p)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => {
fetchDecks()
labApi.getLabStatus().then(setStatus).catch(() => {})
labApi.getLabHistory().then(setHistory).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
try {
const s = await labApi.getLabStatus()
setStatus(s)
if (!s.running && s.run_id) {
const r = await labApi.getLabResult()
setResult(r)
labApi.getLabHistory().then(setHistory).catch(() => {})
}
} catch { /* ignore */ }
}, 2000, status?.running || false)
function toggleDeck(name: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
async function handleStart() {
if (selected.size < 2) { setError('Select at least 2 decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
await labApi.startBatchSim({ decks: [...selected], games })
const s = await labApi.getLabStatus()
setStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleInstallPrecon(name: string) {
try {
await labApi.installPrecon(name)
fetchDecks()
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<FlaskConical className="w-6 h-6 text-accent-teal" />
Batch Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run mass simulations between decks</p>
</div>
<div className="flex gap-5">
{/* Left sidebar: deck selection + import */}
<div className="w-72 flex-shrink-0 space-y-4">
{loading ? (
<div className="flex items-center justify-center h-32"><Spinner size="md" className="text-accent-blue" /></div>
) : (
<DeckSelector
decks={decks}
selected={selected}
onToggle={toggleDeck}
precons={precons}
onInstallPrecon={handleInstallPrecon}
/>
)}
<ImportPanel onImported={fetchDecks} />
</div>
{/* Main content */}
<div className="flex-1 space-y-5">
{/* Config + Start */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<div className="flex items-center gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number"
min={10}
max={10000}
step={10}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-28 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<div className="flex-1">
<p className="text-xs text-text-tertiary">Selected: {selected.size} decks</p>
</div>
{status?.running ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Spinner size="sm" className="text-accent-blue" />
<span className="text-sm text-text-secondary">
{status.games_completed} / {status.total_games} games
</span>
</div>
<div className="w-48 h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all"
style={{ width: `${status.total_games ? (status.games_completed / status.total_games) * 100 : 0}%` }}
/>
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || selected.size < 2}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-teal/15 text-accent-teal text-sm font-semibold rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Simulation
</button>
)}
</div>
{error && (
<div className="mt-3 flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary">{result.total_games} games · {new Date(result.timestamp).toLocaleString()}</span>
</div>
<ResultsChart result={result} />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium uppercase tracking-wider">Deck</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Win Rate</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium uppercase tracking-wider">Avg Turns</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{result.decks.sort((a, b) => b.win_rate - a.win_rate).map((d, i) => (
<tr key={d.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2.5 flex items-center gap-2">
{i === 0 && <Trophy className="w-3.5 h-3.5 text-accent-amber" />}
<span className="font-medium text-text-primary">{d.name}</span>
</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.wins}</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.games}</td>
<td className="px-3 py-2.5 text-right font-semibold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</td>
<td className="px-3 py-2.5 text-right text-text-secondary">{d.avg_turns.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* History */}
<div>
<button onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
<History className="w-4 h-4" />
Run History ({history.length})
<ChevronRight className={`w-3.5 h-3.5 transition-transform ${showHistory ? 'rotate-90' : ''}`} />
</button>
{showHistory && history.length > 0 && (
<div className="mt-3 space-y-2 animate-fade-in">
{history.map(h => (
<div key={h.run_id} className="flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary">
<Clock className="w-4 h-4 text-text-tertiary" />
<div className="flex-1">
<p className="text-sm text-text-primary">{h.decks.join(' vs ')}</p>
<p className="text-xs text-text-tertiary">{new Date(h.timestamp).toLocaleString()} · {h.total_games} games</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}
@@ -1,949 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import {
GraduationCap, MessageSquare, Send, Sparkles, AlertCircle, X, Check,
ChevronRight, ChevronDown, Download, Settings2, Search, Zap,
Minus, Plus, History, ArrowRightLeft, Lightbulb, DollarSign, Loader2,
ClipboardList, Brain, BarChart3
} from 'lucide-react'
import { Spinner, StatusBadge, EmptyState } from '../components/common'
import { coachApi } from '../api'
import type {
CoachStatus, CoachDeck, CoachSession, CoachSessionSummary,
CoachMessage, CoachGoals, CoachApplyResult, CoachCardLikeResult
} from '../types'
// ════════════════════════════════════════════════════════════
// Goals Config Panel
// ════════════════════════════════════════════════════════════
function GoalsPanel({
goals, onChange, collapsed, onToggle
}: {
goals: CoachGoals
onChange: (g: CoachGoals) => void
collapsed: boolean
onToggle: () => void
}) {
const strategies = ['aggro', 'control', 'combo', 'midrange', 'stax', 'voltron', 'aristocrats', 'tokens']
const budgets = [
{ value: 'budget', label: 'Budget', desc: 'Under $5/card' },
{ value: 'medium', label: 'Medium', desc: 'Under $20/card' },
{ value: 'no-limit', label: 'No Limit', desc: 'Any price' },
]
const focusOptions = ['ramp', 'card draw', 'removal', 'protection', 'threats', 'mana base', 'combos', 'synergy']
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary">
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-bg-hover transition-colors rounded-xl"
>
<Settings2 className="w-4 h-4 text-accent-teal" />
<span className="text-sm font-medium text-text-primary flex-1">Coaching Goals</span>
{collapsed ? <ChevronRight className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{!collapsed && (
<div className="px-4 pb-4 space-y-4 border-t border-border-primary pt-3">
{/* Power Level Slider */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1 block">
Power Level: {goals.targetPowerLevel ?? 'Any'}
</label>
<input
type="range" min={1} max={10}
value={goals.targetPowerLevel ?? 5}
onChange={e => onChange({ ...goals, targetPowerLevel: parseInt(e.target.value) })}
className="w-full accent-accent-teal"
/>
<div className="flex justify-between text-[10px] text-text-tertiary">
<span>Casual</span><span>Focused</span><span>cEDH</span>
</div>
</div>
{/* Strategy */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Strategy Focus</label>
<div className="flex flex-wrap gap-1.5">
{strategies.map(s => (
<button
key={s}
onClick={() => onChange({ ...goals, metaFocus: goals.metaFocus === s ? null : s })}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
goals.metaFocus === s
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{s}
</button>
))}
</div>
</div>
{/* Budget */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Budget</label>
<div className="grid grid-cols-3 gap-2">
{budgets.map(b => (
<button
key={b.value}
onClick={() => onChange({ ...goals, budget: goals.budget === b.value ? null : b.value })}
className={`px-3 py-2 text-xs rounded-lg border transition-colors ${
goals.budget === b.value
? 'bg-accent-teal/20 border-accent-teal/40 text-accent-teal'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
<div className="font-medium">{b.label}</div>
<div className="text-[10px] opacity-60">{b.desc}</div>
</button>
))}
</div>
</div>
{/* Focus Areas */}
<div>
<label className="text-xs font-medium text-text-secondary mb-1.5 block">Focus Areas</label>
<div className="flex flex-wrap gap-1.5">
{focusOptions.map(f => {
const active = (goals.focusAreas || []).includes(f)
return (
<button
key={f}
onClick={() => {
const areas = goals.focusAreas || []
onChange({
...goals,
focusAreas: active ? areas.filter(a => a !== f) : [...areas, f]
})
}}
className={`px-2.5 py-1 text-xs rounded-full border transition-colors ${
active
? 'bg-accent-blue/20 border-accent-blue/40 text-accent-blue'
: 'border-border-primary text-text-tertiary hover:border-border-secondary'
}`}
>
{f}
</button>
)
})}
</div>
</div>
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Session View — Shows coaching results with apply functionality
// ════════════════════════════════════════════════════════════
function SessionView({
session, deckId, onClose, onApplied
}: {
session: CoachSession
deckId: number
onClose: () => void
onApplied?: () => void
}) {
const [selectedCuts, setSelectedCuts] = useState<Set<string>>(new Set())
const [selectedAdds, setSelectedAdds] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState(false)
const [applyResult, setApplyResult] = useState<CoachApplyResult | null>(null)
const toggleCut = (name: string) => {
const s = new Set(selectedCuts)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedCuts(s)
}
const toggleAdd = (name: string) => {
const s = new Set(selectedAdds)
s.has(name) ? s.delete(name) : s.add(name)
setSelectedAdds(s)
}
const handleApply = async () => {
if (selectedCuts.size === 0 && selectedAdds.size === 0) return
setApplying(true)
try {
const result = await coachApi.applyCoachSuggestions(
session.sessionId, deckId,
[...selectedCuts], [...selectedAdds]
)
setApplyResult(result)
onApplied?.()
} catch (e) {
console.error('Apply failed:', e)
}
setApplying(false)
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-text-primary">Coaching Session</h3>
<p className="text-xs text-text-tertiary">
{new Date(session.timestamp).toLocaleString()} · {session.modelUsed}
{session.promptTokens > 0 && ` · ${session.promptTokens + session.completionTokens} tokens`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Summary */}
{session.summary && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4">
<h4 className="text-xs font-medium text-text-secondary mb-2 flex items-center gap-1.5">
<Brain className="w-3.5 h-3.5" /> Summary
</h4>
<p className="text-sm text-text-primary leading-relaxed">{session.summary}</p>
</div>
)}
{/* Heuristic Hints */}
{session.heuristicHints?.length > 0 && (
<div className="bg-accent-amber/5 rounded-xl border border-accent-amber/20 p-4">
<h4 className="text-xs font-medium text-accent-amber mb-2 flex items-center gap-1.5">
<Lightbulb className="w-3.5 h-3.5" /> Strategic Tips
</h4>
<ul className="space-y-1.5">
{session.heuristicHints.map((h, i) => (
<li key={i} className="text-sm text-text-primary flex items-start gap-2">
<span className="text-accent-amber mt-0.5">•</span> {h}
</li>
))}
</ul>
</div>
)}
{/* Mana Base Advice */}
{session.manaBaseAdvice && (
<div className="bg-accent-blue/5 rounded-xl border border-accent-blue/20 p-4">
<h4 className="text-xs font-medium text-accent-blue mb-2 flex items-center gap-1.5">
<BarChart3 className="w-3.5 h-3.5" /> Mana Base
</h4>
<p className="text-sm text-text-primary">{session.manaBaseAdvice}</p>
</div>
)}
{/* Suggested Cuts */}
{session.suggestedCuts?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-error mb-2 flex items-center gap-1.5">
<Minus className="w-4 h-4" /> Suggested Cuts ({session.suggestedCuts.length})
</h4>
<div className="space-y-2">
{session.suggestedCuts.map((cut, i) => (
<div
key={i}
onClick={() => toggleCut(cut.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedCuts.has(cut.cardName)
? 'bg-status-error/10 border-status-error/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedCuts.has(cut.cardName)
? 'bg-status-error border-status-error text-white'
: 'border-border-secondary'
}`}>
{selectedCuts.has(cut.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary">{cut.cardName}</p>
<p className="text-xs text-text-secondary mt-0.5">{cut.reason}</p>
{cut.replacementOptions?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Replace with: {cut.replacementOptions.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Suggested Adds */}
{session.suggestedAdds?.length > 0 && (
<div>
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Suggested Adds ({session.suggestedAdds.length})
</h4>
<div className="space-y-2">
{session.suggestedAdds.map((add, i) => (
<div
key={i}
onClick={() => toggleAdd(add.cardName)}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedAdds.has(add.cardName)
? 'bg-status-success/10 border-status-success/30'
: 'bg-bg-secondary border-border-primary hover:border-border-secondary'
}`}
>
<div className={`mt-0.5 w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
selectedAdds.has(add.cardName)
? 'bg-status-success border-status-success text-white'
: 'border-border-secondary'
}`}>
{selectedAdds.has(add.cardName) && <Check className="w-3 h-3" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{add.cardName}</p>
{add.role && (
<span className="px-1.5 py-0.5 text-[10px] bg-accent-blue/15 text-accent-blue rounded">
{add.role}
</span>
)}
</div>
<p className="text-xs text-text-secondary mt-0.5">{add.reason}</p>
{add.synergyWith?.length > 0 && (
<p className="text-[10px] text-text-tertiary mt-1">
Synergy: {add.synergyWith.join(', ')}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Apply Button */}
{!applyResult && (selectedCuts.size > 0 || selectedAdds.size > 0) && (
<div className="sticky bottom-0 bg-bg-primary/95 backdrop-blur-sm border-t border-border-primary py-3 -mx-6 px-6">
<button
onClick={handleApply}
disabled={applying}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
{applying ? <Spinner size="sm" /> : <ArrowRightLeft className="w-4 h-4" />}
Apply {selectedCuts.size > 0 && `${selectedCuts.size} cuts`}
{selectedCuts.size > 0 && selectedAdds.size > 0 && ' + '}
{selectedAdds.size > 0 && `${selectedAdds.size} adds`} to Deck
</button>
</div>
)}
{/* Apply Results */}
{applyResult && (
<div className="bg-status-success/5 rounded-xl border border-status-success/20 p-4">
<h4 className="text-sm font-medium text-status-success mb-2 flex items-center gap-1.5">
<Check className="w-4 h-4" /> Changes Applied
</h4>
<p className="text-sm text-text-primary">
{applyResult.total_cuts > 0 && `Removed ${applyResult.total_cuts} card(s). `}
{applyResult.total_adds > 0 && `Added ${applyResult.total_adds} card(s). `}
{applyResult.errors.length > 0 && `${applyResult.errors.length} error(s).`}
</p>
{applyResult.errors.length > 0 && (
<ul className="mt-2 text-xs text-status-error">
{applyResult.errors.map((e, i) => (
<li key={i}>{e.name}: {e.error}</li>
))}
</ul>
)}
</div>
)}
{/* Raw Explanation */}
{session.rawTextExplanation && (
<details className="group">
<summary className="text-xs text-text-tertiary cursor-pointer hover:text-text-secondary">
Full analysis...
</summary>
<p className="mt-2 text-sm text-text-secondary whitespace-pre-wrap leading-relaxed bg-bg-secondary rounded-lg p-4 border border-border-primary">
{session.rawTextExplanation}
</p>
</details>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Cards Like Search
// ════════════════════════════════════════════════════════════
function CardsLikePanel() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<CoachCardLikeResult[]>([])
const [searching, setSearching] = useState(false)
const handleSearch = async () => {
if (!query.trim()) return
setSearching(true)
try {
const data = await coachApi.searchCardsLike(query.trim(), undefined, 12)
setResults(data.results)
} catch { /* ignore */ }
setSearching(false)
}
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
placeholder="Enter a card name..."
className="w-full pl-9 pr-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue"
/>
</div>
<button
onClick={handleSearch}
disabled={searching || !query.trim()}
className="px-4 py-2 bg-accent-teal/15 text-accent-teal text-sm font-medium rounded-lg hover:bg-accent-teal/25 border border-accent-teal/30 transition-colors disabled:opacity-50"
>
{searching ? <Spinner size="sm" /> : 'Search'}
</button>
</div>
{results.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{results.map((card, i) => (
<div key={i} className="flex items-start gap-3 p-3 bg-bg-secondary rounded-lg border border-border-primary">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary truncate">{card.name}</p>
<span className="text-[10px] text-text-tertiary whitespace-nowrap">
{(card.similarity * 100).toFixed(0)}%
</span>
</div>
<p className="text-[11px] text-text-secondary mt-0.5">{card.types}</p>
{card.text && (
<p className="text-[10px] text-text-tertiary mt-1 line-clamp-2">{card.text}</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-[10px]">
{card.tcg_price != null && (
<span className="text-accent-amber flex items-center gap-0.5">
<DollarSign className="w-2.5 h-2.5" />{card.tcg_price.toFixed(2)}
</span>
)}
{card.owned_qty > 0 && (
<span className="text-status-success">Owned: {card.owned_qty}</span>
)}
<span className="text-text-tertiary">MV: {card.mana_value}</span>
</div>
</div>
{card.image_url && (
<img src={card.image_url} alt={card.name} className="w-14 h-auto rounded" />
)}
</div>
))}
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════
// Live Chat View
// ════════════════════════════════════════════════════════════
function LiveChat({
deckId, deckName, goals, onClose
}: {
deckId: string
deckName: string
goals: CoachGoals
onClose: () => void
}) {
const [messages, setMessages] = useState<CoachMessage[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [streamText, setStreamText] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streamText])
const sendMessage = async () => {
if (!input.trim() || streaming) return
const userMsg: CoachMessage = { role: 'user', content: input.trim() }
const allMessages = [...messages, userMsg]
setMessages(allMessages)
setInput('')
setStreaming(true)
setStreamText('')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? {
targetPowerLevel: goals.targetPowerLevel,
metaFocus: goals.metaFocus,
budget: goals.budget,
focusAreas: goals.focusAreas,
}
: undefined
try {
abortRef.current = new AbortController()
const fullText = await coachApi.coachChatStream(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict,
(text) => setStreamText(text),
abortRef.current.signal
)
setMessages(prev => [...prev, { role: 'assistant', content: fullText }])
setStreamText('')
} catch (e) {
if ((e as Error).name !== 'AbortError') {
// Fallback to non-streaming
try {
const resp = await coachApi.coachChat(
deckId,
allMessages.map(m => ({ role: m.role, content: m.content })),
goalsDict
)
setMessages(prev => [...prev, { role: 'assistant', content: resp.content }])
} catch (e2) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Error: ${(e2 as Error).message || 'Failed to get response'}`
}])
}
}
setStreamText('')
}
setStreaming(false)
}
return (
<div className="flex flex-col h-[calc(100vh-180px)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border-primary">
<div>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-accent-blue" />
Chat: {deckName}
</h3>
<p className="text-[10px] text-text-tertiary">
{goals.metaFocus && `${goals.metaFocus} · `}
{goals.targetPowerLevel && `Power ${goals.targetPowerLevel} · `}
{goals.budget && `${goals.budget}`}
</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-bg-hover text-text-tertiary">
<X className="w-4 h-4" />
</button>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && !streaming && (
<div className="text-center py-12">
<GraduationCap className="w-10 h-10 text-text-tertiary mx-auto mb-3" />
<p className="text-sm text-text-secondary">Ask your coach anything about this deck.</p>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
{[
'What should I cut?',
'How is my mana curve?',
'Suggest win conditions',
'Rate my removal suite',
].map(q => (
<button
key={q}
onClick={() => { setInput(q); }}
className="px-3 py-1.5 text-xs bg-bg-secondary border border-border-primary rounded-full text-text-secondary hover:text-text-primary hover:border-border-secondary transition-colors"
>
{q}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed ${
msg.role === 'user'
? 'bg-accent-blue/15 text-text-primary border border-accent-blue/20'
: 'bg-bg-secondary text-text-primary border border-border-primary'
}`}>
{msg.content}
</div>
</div>
))}
{/* Streaming indicator */}
{streaming && (
<div className="flex justify-start">
<div className="max-w-[85%] px-4 py-3 rounded-xl text-sm whitespace-pre-wrap leading-relaxed bg-bg-secondary text-text-primary border border-border-primary">
{streamText || (
<span className="flex items-center gap-2 text-text-tertiary">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Thinking...
</span>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-border-primary p-3">
<div className="flex gap-2">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && sendMessage()}
placeholder="Ask the coach..."
disabled={streaming}
className="flex-1 px-3 py-2 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue disabled:opacity-50"
/>
<button
onClick={sendMessage}
disabled={streaming || !input.trim()}
className="px-3 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50"
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}
// ════════════════════════════════════════════════════════════
// Main Coach Page
// ════════════════════════════════════════════════════════════
type Tab = 'decks' | 'chat' | 'session' | 'history' | 'search'
export function CoachPage() {
const [status, setStatus] = useState<CoachStatus | null>(null)
const [decks, setDecks] = useState<CoachDeck[]>([])
const [sessions, setSessions] = useState<CoachSessionSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// UI state
const [tab, setTab] = useState<Tab>('decks')
const [selectedDeck, setSelectedDeck] = useState<CoachDeck | null>(null)
const [selectedSession, setSelectedSession] = useState<CoachSession | null>(null)
const [goals, setGoals] = useState<CoachGoals>({})
const [goalsCollapsed, setGoalsCollapsed] = useState(true)
const [generatingReport, setGeneratingReport] = useState(false)
const [runningSession, setRunningSession] = useState<number | null>(null)
const [downloadingEmbeddings, setDownloadingEmbeddings] = useState(false)
const loadData = useCallback(async () => {
try {
const [s, d, sess] = await Promise.all([
coachApi.getCoachStatus(),
coachApi.getCoachDecks().catch(() => []),
coachApi.getCoachSessions().catch(() => ({ sessions: [] })),
])
setStatus(s)
setDecks(Array.isArray(d) ? d : [])
setSessions(Array.isArray(sess) ? sess : (sess as { sessions: CoachSessionSummary[] }).sessions || [])
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadData() }, [loadData])
const handleDownloadEmbeddings = async () => {
setDownloadingEmbeddings(true)
try {
await coachApi.downloadEmbeddings()
const s = await coachApi.getCoachStatus()
setStatus(s)
} catch { /* ignore */ }
setDownloadingEmbeddings(false)
}
const handleGenerateReports = async () => {
setGeneratingReport(true)
setError(null)
try {
await coachApi.generateReport()
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Report generation failed')
}
setGeneratingReport(false)
}
const handleRunSession = async (deck: CoachDeck) => {
setRunningSession(deck.deck_id)
setError(null)
try {
const deckSlug = deck.deck_name.toLowerCase().replace(/\s+/g, '-')
const goalsDict = goals.targetPowerLevel || goals.metaFocus || goals.budget || (goals.focusAreas || []).length > 0
? goals : undefined
const session = await coachApi.runCoachSession(deckSlug, goalsDict as Record<string, unknown>)
setSelectedSession(session)
setSelectedDeck(deck)
setTab('session')
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Session failed')
}
setRunningSession(null)
}
const handleOpenChat = (deck: CoachDeck) => {
setSelectedDeck(deck)
setTab('chat')
}
const handleViewSession = async (sessionId: string) => {
try {
const s = await coachApi.getCoachSession(sessionId)
setSelectedSession(s)
// Find the matching deck
const matchDeck = decks.find(d =>
d.deck_name.toLowerCase().replace(/\s+/g, '-') === s.deckId.toLowerCase() ||
d.deck_name.toLowerCase() === s.deckId.toLowerCase()
)
if (matchDeck) setSelectedDeck(matchDeck)
setTab('session')
} catch { /* ignore */ }
}
if (loading) {
return (
<div className="p-6 flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<GraduationCap className="w-6 h-6 text-accent-amber" />
Deck Coach
</h1>
<p className="text-sm text-text-secondary mt-0.5">
AI-powered deck analysis, coaching, and live chat
</p>
</div>
</div>
{/* Status Bar */}
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge
variant={status?.llmConnected ? 'success' : 'warning'}
label={status?.llmConnected ? `LLM: ${status.llmModel || 'Connected'}` : 'LLM: Disconnected'}
/>
<StatusBadge
variant={status?.embeddingsLoaded ? 'success' : 'neutral'}
label={status?.embeddingsLoaded ? `${(status.embeddingCards || 0).toLocaleString()} embeddings` : 'No embeddings'}
/>
{status?.deckReportsAvailable != null && status.deckReportsAvailable > 0 && (
<StatusBadge variant="success" label={`${status.deckReportsAvailable} reports`} />
)}
{!status?.embeddingsLoaded && (
<button onClick={handleDownloadEmbeddings} disabled={downloadingEmbeddings}
className="flex items-center gap-1.5 text-xs text-accent-blue hover:text-accent-blue-hover transition-colors disabled:opacity-50">
{downloadingEmbeddings ? <Spinner size="sm" /> : <Download className="w-3 h-3" />}
Download Embeddings
</button>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4 flex-shrink-0" />{error}
<button onClick={() => setError(null)} className="ml-auto"><X className="w-3 h-3" /></button>
</div>
)}
{/* Tab Nav */}
<div className="flex gap-1 bg-bg-secondary rounded-lg p-1 border border-border-primary">
{([
{ id: 'decks', label: 'Decks', icon: ClipboardList },
{ id: 'history', label: 'Sessions', icon: History },
{ id: 'search', label: 'Cards Like', icon: Search },
] as { id: Tab; label: string; icon: typeof ClipboardList }[]).map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
tab === t.id
? 'bg-bg-primary text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
<t.icon className="w-3.5 h-3.5" />
{t.label}
{t.id === 'history' && sessions.length > 0 && (
<span className="text-[10px] bg-bg-tertiary px-1.5 rounded-full">{sessions.length}</span>
)}
</button>
))}
{tab === 'chat' && selectedDeck && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-blue shadow-sm">
<MessageSquare className="w-3.5 h-3.5" />
Chat
</div>
)}
{tab === 'session' && selectedSession && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-bg-primary text-accent-amber shadow-sm">
<Sparkles className="w-3.5 h-3.5" />
Results
</div>
)}
</div>
{/* Goals Panel (shown when on decks tab) */}
{tab === 'decks' && (
<GoalsPanel
goals={goals}
onChange={setGoals}
collapsed={goalsCollapsed}
onToggle={() => setGoalsCollapsed(!goalsCollapsed)}
/>
)}
{/* ── Decks Tab ─────────────────────────────────────── */}
{tab === 'decks' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-text-primary">Your Decks</h2>
<button
onClick={handleGenerateReports}
disabled={generatingReport}
className="flex items-center gap-1.5 text-xs text-accent-amber hover:text-accent-amber transition-colors disabled:opacity-50"
>
{generatingReport ? <Spinner size="sm" /> : <Zap className="w-3 h-3" />}
Generate Reports
</button>
</div>
{decks.length === 0 ? (
<EmptyState
icon={GraduationCap}
title="No decks available"
description="Create decks in the Deck Builder to start coaching."
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{decks.map(deck => (
<div key={deck.deck_id} className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">{deck.deck_name}</h3>
<p className="text-xs text-text-secondary">{deck.commander}</p>
</div>
<div className="flex items-center gap-3 text-[10px] text-text-tertiary">
<span>{deck.card_count} cards</span>
{deck.has_report && (
<span className="flex items-center gap-1 text-status-success">
<Check className="w-2.5 h-2.5" /> Report
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleRunSession(deck)}
disabled={runningSession === deck.deck_id || !status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-amber/15 text-accent-amber text-xs font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-50"
>
{runningSession === deck.deck_id ? <Spinner size="sm" /> : <Sparkles className="w-3 h-3" />}
Analyze
</button>
<button
onClick={() => handleOpenChat(deck)}
disabled={!status?.llmConnected}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-accent-blue/15 text-accent-blue text-xs font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-50"
>
<MessageSquare className="w-3 h-3" />
Chat
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── Chat Tab ──────────────────────────────────────── */}
{tab === 'chat' && selectedDeck && (
<LiveChat
deckId={selectedDeck.deck_name.toLowerCase().replace(/\s+/g, '-')}
deckName={selectedDeck.deck_name}
goals={goals}
onClose={() => setTab('decks')}
/>
)}
{/* ── Session Results Tab ───────────────────────────── */}
{tab === 'session' && selectedSession && selectedDeck && (
<SessionView
session={selectedSession}
deckId={selectedDeck.deck_id}
onClose={() => setTab('decks')}
onApplied={() => loadData()}
/>
)}
{/* ── History Tab ───────────────────────────────────── */}
{tab === 'history' && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-text-primary">Coaching Sessions</h2>
{sessions.length === 0 ? (
<EmptyState
icon={History}
title="No sessions yet"
description="Run an analysis on a deck to create your first coaching session."
/>
) : (
<div className="space-y-2">
{sessions.map(s => (
<button
key={s.sessionId}
onClick={() => handleViewSession(s.sessionId)}
className="w-full flex items-center gap-4 px-4 py-3 bg-bg-secondary rounded-lg border border-border-primary hover:border-border-secondary text-left transition-colors"
>
<Sparkles className="w-4 h-4 text-accent-amber flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-text-primary">{s.deckId}</p>
<span className="text-[10px] text-text-tertiary">
{s.cutsCount > 0 && `${s.cutsCount} cuts`}
{s.cutsCount > 0 && s.addsCount > 0 && ' · '}
{s.addsCount > 0 && `${s.addsCount} adds`}
</span>
</div>
<p className="text-xs text-text-tertiary mt-0.5 truncate">{s.summary || 'No summary'}</p>
<p className="text-[10px] text-text-tertiary mt-0.5">{new Date(s.timestamp).toLocaleString()}</p>
</div>
<ChevronRight className="w-4 h-4 text-text-tertiary flex-shrink-0" />
</button>
))}
</div>
)}
</div>
)}
{/* ── Cards Like Tab ────────────────────────────────── */}
{tab === 'search' && (
<div className="space-y-4">
<div>
<h2 className="text-sm font-semibold text-text-primary mb-1">Cards Like This</h2>
<p className="text-xs text-text-secondary">
Find similar cards using AI embeddings. Search by card name to discover alternatives.
</p>
</div>
<CardsLikePanel />
</div>
)}
</div>
)
}
@@ -1,484 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Search, Filter, Upload, Download, Camera, RefreshCw,
ChevronLeft, ChevronRight, X, Package, SlidersHorizontal,
Plus, Grid3x3, List
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, CardImage } from '../components/common'
import { collectionApi } from '../api'
import type { CollectionCard, CollectionFilters, SetInfo } from '../types'
// ── Card Detail Modal ─────────────────────────────────────────
function CardDetailModal({ card, onClose }: { card: CollectionCard; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div
className="bg-bg-secondary rounded-xl border border-border-primary shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto animate-fade-in"
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between p-5 border-b border-border-primary">
<div>
<h2 className="text-xl font-bold text-text-primary">{card.name}</h2>
<p className="text-sm text-text-secondary mt-0.5">{card.type_line}</p>
</div>
<button onClick={onClose} className="p-1 rounded hover:bg-bg-hover text-text-tertiary">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col md:flex-row gap-6 p-5">
{/* Large card image */}
<div className="flex-shrink-0">
<CardImage src={card.image_url} alt={card.name} size="xl" />
</div>
{/* Card info */}
<div className="flex-1 space-y-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Mana Cost</label>
<div className="mt-1"><ManaSymbols cost={card.mana_cost} size="md" /></div>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Oracle Text</label>
<p className="mt-1 text-sm text-text-primary whitespace-pre-wrap leading-relaxed">{card.oracle_text}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<p className="mt-1 text-sm text-text-primary">{card.set_name} ({card.set_code.toUpperCase()})</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<p className={`mt-1 text-sm font-medium capitalize ${
card.rarity === 'mythic' ? 'text-accent-amber' :
card.rarity === 'rare' ? 'text-accent-amber/80' :
card.rarity === 'uncommon' ? 'text-accent-teal' : 'text-text-secondary'
}`}>{card.rarity}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC</label>
<p className="mt-1 text-sm text-text-primary">{card.cmc}</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">EDHREC Rank</label>
<p className="mt-1 text-sm text-text-primary">{card.edhrec_rank ?? 'N/A'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Quantity</label>
<p className="mt-1 text-sm text-text-primary">{card.quantity} (+ {card.foil_quantity} foil)</p>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Price</label>
<p className="mt-1 text-sm text-accent-teal font-semibold">
${card.tcg_price?.toFixed(2) ?? '—'}
{card.foil_price ? <span className="text-text-tertiary ml-2">Foil: ${card.foil_price.toFixed(2)}</span> : null}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// ── Scanner Panel ─────────────────────────────────────────────
function ScannerPanel({ onCardAdded }: { onCardAdded: () => void }) {
const [scanning, setScanning] = useState(false)
const [results, setResults] = useState<import('../types').ScanMatch[]>([])
const [adding, setAdding] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
async function handleScan(file: File) {
setScanning(true)
setResults([])
try {
const res = await collectionApi.scanCard(file)
setResults(res.matches || [])
} catch {
setResults([])
}
setScanning(false)
}
async function handleAdd(match: import('../types').ScanMatch) {
setAdding(match.scryfall_id)
try {
await collectionApi.addScanResult({
name: match.name,
set_code: match.set_code,
scryfall_id: match.scryfall_id,
})
onCardAdded()
} catch { /* ignore */ }
setAdding(null)
}
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2 mb-3">
<Camera className="w-4 h-4 text-accent-blue" />
Card Scanner
</h3>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => e.target.files?.[0] && handleScan(e.target.files[0])}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={scanning}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-accent-blue/10 text-accent-blue border border-accent-blue/30 rounded-lg text-sm font-medium hover:bg-accent-blue/20 transition-colors disabled:opacity-50"
>
{scanning ? <Spinner size="sm" /> : <Camera className="w-4 h-4" />}
{scanning ? 'Scanning...' : 'Scan a Card'}
</button>
{results.length > 0 && (
<div className="mt-3 space-y-2 max-h-60 overflow-y-auto">
{results.map(m => (
<div key={m.scryfall_id} className="flex items-center gap-3 p-2 rounded-lg bg-bg-tertiary">
<CardImage src={m.image_url} alt={m.name} size="sm" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{m.name}</p>
<p className="text-xs text-text-tertiary">{m.set_name} — {(m.confidence * 100).toFixed(0)}%</p>
</div>
<button
onClick={() => handleAdd(m)}
disabled={adding === m.scryfall_id}
className="p-1.5 rounded-lg bg-accent-green/15 text-accent-green hover:bg-accent-green/25 transition-colors"
>
{adding === m.scryfall_id ? <Spinner size="sm" /> : <Plus className="w-4 h-4" />}
</button>
</div>
))}
</div>
)}
</div>
)
}
// ── Main Collection Page ──────────────────────────────────────
export function CollectionPage() {
const [cards, setCards] = useState<CollectionCard[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [sets, setSets] = useState<SetInfo[]>([])
const [selectedCard, setSelectedCard] = useState<CollectionCard | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [showFilters, setShowFilters] = useState(false)
const [importing, setImporting] = useState(false)
const [enriching, setEnriching] = useState(false)
const importRef = useRef<HTMLInputElement>(null)
const [filters, setFilters] = useState<CollectionFilters>({
q: '',
page: 1,
page_size: 48,
sort_by: 'name',
sort_dir: 'asc',
})
const fetchCards = useCallback(async () => {
setLoading(true)
try {
const res = await collectionApi.searchCollection(filters)
setCards(res.cards)
setTotal(res.total)
} catch { /* ignore */ }
setLoading(false)
}, [filters])
useEffect(() => { fetchCards() }, [fetchCards])
useEffect(() => {
collectionApi.getSets().then(setSets).catch(() => {})
}, [])
const totalPages = Math.ceil(total / (filters.page_size || 48))
function updateFilter(key: keyof CollectionFilters, value: string | number | boolean | undefined) {
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
}
async function handleExport() {
const blob = await collectionApi.exportCollection('csv')
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'collection.csv'
a.click()
URL.revokeObjectURL(url)
}
async function handleImport(file: File) {
setImporting(true)
try {
await collectionApi.importCollection(file)
fetchCards()
} catch { /* ignore */ }
setImporting(false)
}
async function handleReEnrich() {
setEnriching(true)
try {
await collectionApi.reEnrichCards()
fetchCards()
} catch { /* ignore */ }
setEnriching(false)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Collection</h1>
<p className="text-sm text-text-secondary mt-0.5">{total.toLocaleString()} cards in your collection</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleReEnrich} disabled={enriching}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{enriching ? <Spinner size="sm" /> : <RefreshCw className="w-3.5 h-3.5" />}
Re-Enrich
</button>
<input ref={importRef} type="file" accept=".csv,.txt" className="hidden"
onChange={e => e.target.files?.[0] && handleImport(e.target.files[0])} />
<button onClick={() => importRef.current?.click()} disabled={importing}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50">
{importing ? <Spinner size="sm" /> : <Upload className="w-3.5 h-3.5" />}
Import
</button>
<button onClick={handleExport}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export
</button>
</div>
</div>
<div className="flex gap-5">
{/* Left sidebar: search + filters + scanner */}
<div className="w-72 flex-shrink-0 space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search cards..."
value={filters.q || ''}
onChange={e => updateFilter('q', e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 focus:ring-1 focus:ring-accent-blue/30 transition-all"
/>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full flex items-center justify-between px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-secondary hover:bg-bg-hover transition-colors"
>
<span className="flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4" />
Filters
</span>
<Filter className={`w-3.5 h-3.5 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</button>
{showFilters && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 space-y-3 animate-fade-in">
{/* Rarity */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Rarity</label>
<select
value={filters.rarity || ''}
onChange={e => updateFilter('rarity', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="common">Common</option>
<option value="uncommon">Uncommon</option>
<option value="rare">Rare</option>
<option value="mythic">Mythic</option>
</select>
</div>
{/* Color */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Color</label>
<select
value={filters.color || ''}
onChange={e => updateFilter('color', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All</option>
<option value="W">White</option>
<option value="U">Blue</option>
<option value="B">Black</option>
<option value="R">Red</option>
<option value="G">Green</option>
<option value="C">Colorless</option>
</select>
</div>
{/* Set */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Set</label>
<select
value={filters.set_code || ''}
onChange={e => updateFilter('set_code', e.target.value || undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="">All Sets</option>
{sets.map(s => (
<option key={s.code} value={s.code}>{s.name} ({s.card_count})</option>
))}
</select>
</div>
{/* CMC Range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Min</label>
<input type="number" min="0" max="20"
value={filters.cmc_min ?? ''}
onChange={e => updateFilter('cmc_min', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">CMC Max</label>
<input type="number" min="0" max="20"
value={filters.cmc_max ?? ''}
onChange={e => updateFilter('cmc_max', e.target.value ? Number(e.target.value) : undefined)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
/>
</div>
</div>
{/* Sort */}
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Sort By</label>
<select
value={filters.sort_by || 'name'}
onChange={e => updateFilter('sort_by', e.target.value)}
className="mt-1 w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50"
>
<option value="name">Name</option>
<option value="cmc">CMC</option>
<option value="tcg_price">Price</option>
<option value="rarity">Rarity</option>
<option value="set_code">Set</option>
<option value="added_date">Date Added</option>
</select>
</div>
</div>
)}
{/* Scanner */}
<ScannerPanel onCardAdded={fetchCards} />
</div>
{/* Main content: card grid/list */}
<div className="flex-1 min-w-0">
{/* View toggle + pagination */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-1 bg-bg-secondary rounded-lg border border-border-primary p-0.5">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'grid' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<Grid3x3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-1.5 rounded-md transition-colors ${viewMode === 'list' ? 'bg-bg-hover text-text-primary' : 'text-text-tertiary hover:text-text-secondary'}`}
>
<List className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>Page {filters.page} of {totalPages || 1}</span>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.max(1, (p.page || 1) - 1) }))}
disabled={!filters.page || filters.page <= 1}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setFilters(p => ({ ...p, page: Math.min(totalPages, (p.page || 1) + 1) }))}
disabled={(filters.page || 1) >= totalPages}
className="p-1 rounded hover:bg-bg-hover disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<Spinner size="lg" className="text-accent-blue" />
</div>
) : cards.length === 0 ? (
<EmptyState
icon={Package}
title="No cards found"
description="Try adjusting your search or filters, or import a collection."
/>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
{cards.map(card => (
<div
key={card.id}
className="group cursor-pointer"
onClick={() => setSelectedCard(card)}
>
<CardImage src={card.image_url} alt={card.name} size="md" className="w-full h-auto aspect-[5/7]" />
<div className="mt-1.5 px-0.5">
<p className="text-xs font-medium text-text-primary truncate group-hover:text-accent-blue transition-colors">{card.name}</p>
<div className="flex items-center justify-between">
<span className="text-[10px] text-text-tertiary">{card.set_code.toUpperCase()}</span>
{card.tcg_price != null && (
<span className="text-[10px] text-accent-teal font-medium">${card.tcg_price.toFixed(2)}</span>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary text-left">
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Card</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Mana</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Set</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider">Qty</th>
<th className="px-4 py-3 text-xs text-text-tertiary font-medium uppercase tracking-wider text-right">Price</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{cards.map(card => (
<tr key={card.id} className="hover:bg-bg-hover cursor-pointer transition-colors" onClick={() => setSelectedCard(card)}>
<td className="px-4 py-2.5">
<span className="font-medium text-text-primary">{card.name}</span>
</td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.type_line}</td>
<td className="px-4 py-2.5"><ManaSymbols cost={card.mana_cost} /></td>
<td className="px-4 py-2.5 text-text-secondary text-xs">{card.set_code.toUpperCase()}</td>
<td className="px-4 py-2.5 text-text-secondary">{card.quantity}</td>
<td className="px-4 py-2.5 text-right text-accent-teal font-medium">{card.tcg_price != null ? `$${card.tcg_price.toFixed(2)}` : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Card detail modal */}
{selectedCard && <CardDetailModal card={selectedCard} onClose={() => setSelectedCard(null)} />}
</div>
)
}
@@ -1,967 +0,0 @@
import { useState, useEffect } from 'react'
import {
Plus, Trash2, Download, ChevronRight,
BarChart3, Lightbulb, Layers, X, Library,
Globe, XCircle, ShoppingCart,
ChevronDown, ChevronUp, Sparkles
} from 'lucide-react'
import { Spinner, EmptyState, ManaSymbols, ColorDots } from '../components/common'
import { decksApi } from '../api'
import type {
Deck, DeckCard, DeckAnalysis,
EdhRecCard, EdhRecsResponse,
CollectionRecsResponse
} from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
// ── Role Badge ──────────────────────────────────────────────
const ROLE_COLORS: Record<string, string> = {
Ramp: 'bg-green-500/15 text-green-400 border-green-500/25',
Draw: 'bg-blue-500/15 text-blue-400 border-blue-500/25',
Removal: 'bg-red-500/15 text-red-400 border-red-500/25',
BoardWipe: 'bg-orange-500/15 text-orange-400 border-orange-500/25',
Tutor: 'bg-purple-500/15 text-purple-400 border-purple-500/25',
Protection: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/25',
Finisher: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/25',
Recursion: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/25',
Other: 'bg-slate-500/15 text-slate-400 border-slate-500/25',
}
function RoleBadge({ role }: { role: string }) {
const colors = ROLE_COLORS[role] || ROLE_COLORS.Other
return (
<span className={'text-[10px] font-medium px-1.5 py-0.5 rounded border ' + colors}>
{role}
</span>
)
}
// ── Deck Card List ────────────────────────────────────────────
function DeckCardList({ deckId, cards, onUpdate }: { deckId: number; cards: DeckCard[]; onUpdate: () => void }) {
const [removing, setRemoving] = useState<number | null>(null)
// Group cards by category
const grouped = cards.reduce<Record<string, DeckCard[]>>((acc, c) => {
const cat = c.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(c)
return acc
}, {})
const categoryOrder = ['Commander', 'Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const ia = categoryOrder.indexOf(a)
const ib = categoryOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
async function handleRemove(cardId: number) {
setRemoving(cardId)
try {
await decksApi.removeCardFromDeck(deckId, cardId)
onUpdate()
} catch { /* ignore */ }
setRemoving(null)
}
return (
<div className="space-y-4">
{sortedCategories.map(cat => (
<div key={cat}>
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider mb-2">
{cat} ({grouped[cat].length})
</h4>
<div className="space-y-1">
{grouped[cat].map(card => (
<div key={card.id} className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-bg-hover group transition-colors">
<span className="text-xs text-text-tertiary w-5 text-right">{card.quantity}x</span>
<ManaSymbols cost={card.mana_cost} />
<span className="flex-1 text-sm text-text-primary truncate">{card.name}</span>
{card.tcg_price != null && (
<span className="text-xs text-accent-teal">${card.tcg_price.toFixed(2)}</span>
)}
{!card.is_commander && (
<button
onClick={() => handleRemove(card.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-text-tertiary hover:text-accent-red transition-all"
>
{removing === card.id ? <Spinner size="sm" /> : <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}
// ── Mana Curve Chart ──────────────────────────────────────────
function ManaCurveChart({ analysis }: { analysis: DeckAnalysis }) {
const data = Object.entries(analysis.mana_curve)
.map(([cmc, count]) => ({ cmc: cmc === '7' ? '7+' : cmc, count }))
.sort((a, b) => Number(a.cmc) - Number(b.cmc))
return (
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} barSize={24}>
<XAxis dataKey="cmc" axisLine={false} tickLine={false} tick={{ fill: '#6b7190', fontSize: 11 }} />
<YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
labelStyle={{ color: '#9ba1b8' }}
itemStyle={{ color: '#e8eaf0' }}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, i) => (
<Cell key={i} fill="#4f6ef7" fillOpacity={0.8} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
// ── EDHREC Recommendations Sub-tab ──────────────────────────
function EdhRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<EdhRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [onlyOwned, setOnlyOwned] = useState(false)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [hoveredCard, setHoveredCard] = useState<EdhRecCard | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getEdhRecs(deckId, onlyOwned)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load EDHREC recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId, onlyOwned])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
setSelectedCards(new Set(data.recommendations.map(r => r.name)))
}
function selectNone() { setSelectedCards(new Set()) }
function selectOwned() {
if (!data) return
setSelectedCards(new Set(data.recommendations.filter(r => r.owned).map(r => r.name)))
}
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from list
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => r.name !== name),
total: data.total - 1,
})
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
setData({
...data,
recommendations: data.recommendations.filter(r => !selectedCards.has(r.name)),
total: data.total - selectedCards.size,
})
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
// Group recommendations by role
function groupByRole(recs: EdhRecCard[]): Record<string, EdhRecCard[]> {
const groups: Record<string, EdhRecCard[]> = {}
for (const rec of recs) {
const role = rec.role || 'Other'
if (!groups[role]) groups[role] = []
groups[role].push(rec)
}
return groups
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.recommendations.length === 0) {
return <EmptyState icon={Globe} title="No EDHREC data" description="No recommendations found for this commander on EDHREC." />
}
const grouped = groupByRole(data.recommendations)
const roleOrder = ['Ramp', 'Draw', 'Removal', 'BoardWipe', 'Tutor', 'Protection', 'Finisher', 'Recursion', 'Other']
const sortedRoles = Object.keys(grouped).sort((a, b) => {
const ia = roleOrder.indexOf(a)
const ib = roleOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
const ownedCount = data.recommendations.filter(r => r.owned).length
const missingCount = data.recommendations.length - ownedCount
return (
<div className="space-y-4">
{/* Header bar */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm">
<Sparkles className="w-4 h-4 text-accent-blue" />
<span className="text-text-primary font-medium">{data.commander}</span>
<span className="text-text-tertiary">via {data.source}</span>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green">{ownedCount} owned</span>
<span className="text-xs px-2 py-0.5 rounded bg-bg-tertiary text-text-tertiary">{missingCount} missing</span>
</div>
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input
type="checkbox"
checked={onlyOwned}
onChange={e => setOnlyOwned(e.target.checked)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5"
/>
Show only owned
</label>
</div>
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectOwned} className="text-xs text-accent-blue hover:underline">Owned</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedRoles.map(role => {
const cards = grouped[role]
const isCollapsed = collapsedGroups.has(role)
return (
<div key={role} className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
{/* Group header */}
<button
onClick={() => toggleGroup(role)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<RoleBadge role={role} />
<span className="text-xs text-text-tertiary">({cards.length})</span>
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div
key={rec.name}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group relative"
onMouseEnter={() => setHoveredCard(rec)}
onMouseLeave={() => setHoveredCard(null)}
>
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary truncate">{rec.type_line}</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Owned badge */}
<span className={'text-xs px-2 py-0.5 rounded flex-shrink-0 ' + (rec.owned ? 'bg-accent-green/15 text-accent-green' : 'bg-bg-tertiary text-text-tertiary')}>
{rec.owned ? (rec.owned_qty + 'x owned') : 'Missing'}
</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
{/* Hover preview */}
{hoveredCard && hoveredCard.image_url && (
<div className="fixed top-20 right-8 z-50 pointer-events-none animate-fade-in">
<img
src={hoveredCard.image_url}
alt={hoveredCard.name}
className="w-[244px] rounded-xl shadow-2xl shadow-black/60 border border-border-primary"
/>
</div>
)}
</div>
)
}
// ── Collection Recommendations Sub-tab ──────────────────────
function CollectionRecTab({ deckId, onAddedCards }: { deckId: number; onAddedCards: () => void }) {
const [data, setData] = useState<CollectionRecsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCards, setSelectedCards] = useState<Set<string>>(new Set())
const [addingCards, setAddingCards] = useState<Set<string>>(new Set())
const [bulkAdding, setBulkAdding] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
async function fetchRecs() {
setLoading(true)
setError(null)
try {
const result = await decksApi.getCollectionRecs(deckId)
setData(result)
} catch (e: any) {
setError(e?.message || 'Failed to load collection recommendations')
}
setLoading(false)
}
useEffect(() => { fetchRecs() }, [deckId])
function toggleSelect(name: string) {
setSelectedCards(prev => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
function selectAll() {
if (!data) return
const all = new Set<string>()
Object.values(data.grouped).flat().forEach(c => all.add(c.name))
setSelectedCards(all)
}
function selectNone() { setSelectedCards(new Set()) }
async function handleAddSingle(name: string) {
setAddingCards(prev => new Set(prev).add(name))
try {
await decksApi.bulkAddRecommended(deckId, [name])
onAddedCards()
// Remove from groupings
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => c.name !== name)
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - 1 })
}
selectedCards.delete(name)
setSelectedCards(new Set(selectedCards))
} catch { /* ignore */ }
setAddingCards(prev => { const n = new Set(prev); n.delete(name); return n })
}
async function handleBulkAdd() {
if (selectedCards.size === 0) return
setBulkAdding(true)
try {
await decksApi.bulkAddRecommended(deckId, Array.from(selectedCards))
onAddedCards()
if (data) {
const newGrouped = { ...data.grouped }
for (const key of Object.keys(newGrouped)) {
newGrouped[key] = newGrouped[key].filter(c => !selectedCards.has(c.name))
if (newGrouped[key].length === 0) delete newGrouped[key]
}
setData({ ...data, grouped: newGrouped, total: data.total - selectedCards.size })
}
setSelectedCards(new Set())
} catch { /* ignore */ }
setBulkAdding(false)
}
function toggleGroup(groupName: string) {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(groupName)) next.delete(groupName)
else next.add(groupName)
return next
})
}
if (loading) {
return <div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
}
if (error) {
return (
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-5 text-center">
<XCircle className="w-6 h-6 text-accent-red mx-auto mb-2" />
<p className="text-sm text-accent-red font-medium">{error}</p>
<button onClick={fetchRecs} className="mt-3 px-4 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
Retry
</button>
</div>
)
}
if (!data || data.total === 0) {
return <EmptyState icon={Library} title="No collection matches" description="No cards in your collection match this deck's needs." />
}
const typeOrder = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Other']
const sortedTypes = Object.keys(data.grouped).sort((a, b) => {
const ia = typeOrder.indexOf(a)
const ib = typeOrder.indexOf(b)
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib)
})
return (
<div className="space-y-4">
{/* Shortfall alert */}
{data.shortfall_types.length > 0 && (
<div className="flex items-start gap-3 bg-accent-blue/8 border border-accent-blue/20 rounded-xl px-4 py-3">
<Lightbulb className="w-4 h-4 text-accent-blue mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs font-medium text-accent-blue">Deck needs more:</p>
<div className="flex flex-wrap gap-1.5 mt-1">
{data.shortfall_types.map(t => (
<span key={t} className="text-[10px] font-medium px-2 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">{t}</span>
))}
</div>
</div>
</div>
)}
{/* Selection toolbar */}
<div className="flex items-center justify-between bg-bg-secondary rounded-xl border border-border-primary px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-xs text-text-tertiary">Select:</span>
<button onClick={selectAll} className="text-xs text-accent-blue hover:underline">All</button>
<span className="text-text-tertiary">/</span>
<button onClick={selectNone} className="text-xs text-accent-blue hover:underline">None</button>
{selectedCards.size > 0 && (
<span className="text-xs text-text-secondary ml-2">({selectedCards.size} selected)</span>
)}
</div>
<button
onClick={handleBulkAdd}
disabled={selectedCards.size === 0 || bulkAdding}
className="flex items-center gap-2 px-3 py-1.5 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{bulkAdding ? <Spinner size="sm" /> : <ShoppingCart className="w-3.5 h-3.5" />}
Add {selectedCards.size > 0 ? selectedCards.size : ''} to Deck
</button>
</div>
{/* Grouped cards */}
{sortedTypes.map(type => {
const cards = data.grouped[type]
const isCollapsed = collapsedGroups.has(type)
const isShortfall = data.shortfall_types.includes(type)
return (
<div key={type} className={'bg-bg-secondary rounded-xl border overflow-hidden ' + (isShortfall ? 'border-accent-blue/30' : 'border-border-primary')}>
{/* Group header */}
<button
onClick={() => toggleGroup(type)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-primary uppercase tracking-wider">{type}</span>
<span className="text-xs text-text-tertiary">({cards.length})</span>
{isShortfall && <span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-accent-blue/15 text-accent-blue border border-accent-blue/25">Shortfall</span>}
</div>
{isCollapsed ? <ChevronDown className="w-4 h-4 text-text-tertiary" /> : <ChevronUp className="w-4 h-4 text-text-tertiary" />}
</button>
{/* Card list */}
{!isCollapsed && (
<div className="border-t border-border-primary divide-y divide-border-primary">
{cards.map(rec => (
<div key={rec.scryfall_id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-bg-hover transition-colors group">
{/* Checkbox */}
<input
type="checkbox"
checked={selectedCards.has(rec.name)}
onChange={() => toggleSelect(rec.name)}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5 flex-shrink-0"
/>
{/* Card image mini */}
{rec.image_url && (
<div className="w-8 h-11 rounded overflow-hidden bg-bg-tertiary flex-shrink-0">
<img src={rec.image_url} alt={rec.name} className="w-full h-full object-cover" loading="lazy" />
</div>
)}
{/* Card info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">{rec.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-text-tertiary">{rec.cmc} CMC</span>
{rec.roles.length > 0 && rec.roles.map(r => <RoleBadge key={r} role={r} />)}
</div>
</div>
{/* Score */}
<span className="text-xs text-accent-teal font-medium flex-shrink-0">Score: {rec.score}</span>
{/* Owned qty */}
<span className="text-xs px-2 py-0.5 rounded bg-accent-green/15 text-accent-green flex-shrink-0">{rec.owned_qty}x</span>
{/* Add button */}
<button
onClick={e => { e.stopPropagation(); handleAddSingle(rec.name) }}
disabled={addingCards.has(rec.name)}
className="opacity-0 group-hover:opacity-100 px-2.5 py-1 text-xs font-medium bg-accent-blue text-white rounded-lg hover:bg-accent-blue-hover transition-all disabled:opacity-50 flex-shrink-0"
>
{addingCards.has(rec.name) ? <Spinner size="sm" /> : '+ Add'}
</button>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)
}
// ── Deck Detail View ──────────────────────────────────────────
function DeckDetail({ deck, onBack }: { deck: Deck; onBack: () => void }) {
const [cards, setCards] = useState<DeckCard[]>([])
const [analysis, setAnalysis] = useState<DeckAnalysis | null>(null)
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<'cards' | 'analysis' | 'recs'>('cards')
const [recSource, setRecSource] = useState<'edhrec' | 'collection'>('edhrec')
const [addingCard, setAddingCard] = useState('')
const [bulkText, setBulkText] = useState('')
const [showBulk, setShowBulk] = useState(false)
async function fetchAll() {
setLoading(true)
try {
const [c, a] = await Promise.all([
decksApi.getDeckCards(deck.id),
decksApi.getDeckAnalysis(deck.id),
])
setCards(c)
setAnalysis(a)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchAll() }, [deck.id])
async function handleAddCard() {
if (!addingCard.trim()) return
try {
await decksApi.addCardToDeck(deck.id, { name: addingCard.trim() })
setAddingCard('')
fetchAll()
} catch { /* ignore */ }
}
async function handleBulkAdd() {
if (!bulkText.trim()) return
const cardLines = bulkText.split('\n').filter(l => l.trim())
const cardParsed = cardLines.map(l => {
const match = l.match(/^(\d+)x?\s+(.+)$/)
return match ? { name: match[2].trim(), quantity: Number(match[1]) } : { name: l.trim() }
})
try {
await decksApi.bulkAddCards(deck.id, cardParsed)
setBulkText('')
setShowBulk(false)
fetchAll()
} catch { /* ignore */ }
}
async function handleExportToSim() {
try {
await decksApi.exportDeckToSim(deck.id)
} catch { /* ignore */ }
}
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={onBack} className="p-2 rounded-lg hover:bg-bg-hover text-text-secondary transition-colors">
<ChevronRight className="w-5 h-5 rotate-180" />
</button>
<div className="flex-1">
<h2 className="text-xl font-bold text-text-primary">{deck.name}</h2>
<div className="flex items-center gap-3 mt-1">
<span className="text-sm text-text-secondary">{deck.commander}</span>
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
<span className="text-xs text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
<button onClick={handleExportToSim}
className="flex items-center gap-2 px-3 py-2 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Download className="w-3.5 h-3.5" />
Export to Sim
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-border-primary">
{(['cards', 'analysis', 'recs'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors capitalize ${
tab === t
? 'border-accent-blue text-accent-blue'
: 'border-transparent text-text-tertiary hover:text-text-secondary'
}`}
>
{t === 'recs' ? 'Recommendations' : t}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : tab === 'cards' ? (
<div className="space-y-4">
{/* Add card */}
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Add card by name..."
value={addingCard}
onChange={e => setAddingCard(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCard()}
className="flex-1 px-4 py-2.5 bg-bg-secondary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleAddCard}
className="px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Add
</button>
<button onClick={() => setShowBulk(!showBulk)}
className="px-3 py-2.5 bg-bg-tertiary text-text-secondary text-sm rounded-lg hover:bg-bg-hover border border-border-primary transition-colors">
<Layers className="w-4 h-4" />
</button>
</div>
{showBulk && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 animate-fade-in space-y-3">
<textarea
placeholder={"1x Sol Ring\n1x Command Tower\n2x Counterspell"}
value={bulkText}
onChange={e => setBulkText(e.target.value)}
rows={6}
className="w-full px-4 py-3 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary font-mono focus:outline-none focus:border-accent-blue/50 transition-all resize-none"
/>
<button onClick={handleBulkAdd}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Bulk Add
</button>
</div>
)}
<DeckCardList deckId={deck.id} cards={cards} onUpdate={fetchAll} />
</div>
) : tab === 'analysis' && analysis ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Stats */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-accent-blue" />
Deck Stats
</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Cards', value: analysis.card_count },
{ label: 'Lands', value: analysis.land_count },
{ label: 'Creatures', value: analysis.creature_count },
{ label: 'Non-Creature', value: analysis.noncreature_count },
{ label: 'Avg CMC', value: analysis.avg_cmc.toFixed(2) },
{ label: 'Owned', value: `${analysis.owned_count} / ${analysis.card_count}` },
].map(stat => (
<div key={stat.label} className="px-3 py-2 rounded-lg bg-bg-tertiary">
<p className="text-xs text-text-tertiary">{stat.label}</p>
<p className="text-lg font-bold text-text-primary">{stat.value}</p>
</div>
))}
</div>
</div>
{/* Mana Curve */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4">
<h3 className="text-sm font-semibold text-text-primary">Mana Curve</h3>
<ManaCurveChart analysis={analysis} />
</div>
{/* Type Distribution */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-3 md:col-span-2">
<h3 className="text-sm font-semibold text-text-primary">Type Distribution</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(analysis.type_distribution).map(([type, count]) => (
<span key={type} className="px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs text-text-secondary">
{type}: <span className="font-semibold text-text-primary">{count}</span>
</span>
))}
</div>
</div>
</div>
) : tab === 'recs' ? (
<div className="space-y-4">
{/* Sub-tab toggle */}
<div className="flex items-center gap-1 bg-bg-tertiary rounded-lg p-1 w-fit">
<button
onClick={() => setRecSource('edhrec')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'edhrec'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Globe className="w-3.5 h-3.5" />
EDHREC
</button>
<button
onClick={() => setRecSource('collection')}
className={'flex items-center gap-1.5 px-3.5 py-2 text-xs font-medium rounded-md transition-all ' +
(recSource === 'collection'
? 'bg-accent-blue text-white shadow-sm'
: 'text-text-tertiary hover:text-text-secondary')}
>
<Library className="w-3.5 h-3.5" />
From Collection
</button>
</div>
{/* Sub-tab content */}
{recSource === 'edhrec' ? (
<EdhRecTab deckId={deck.id} onAddedCards={fetchAll} />
) : (
<CollectionRecTab deckId={deck.id} onAddedCards={fetchAll} />
)}
</div>
) : null}
</div>
)
}
// ── Main Decks Page ───────────────────────────────────────────
export function DecksPage() {
const [decks, setDecks] = useState<Deck[]>([])
const [loading, setLoading] = useState(true)
const [selectedDeck, setSelectedDeck] = useState<Deck | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [newDeckName, setNewDeckName] = useState('')
const [newCommander, setNewCommander] = useState('')
const [creating, setCreating] = useState(false)
async function fetchDecks() {
setLoading(true)
try {
const d = await decksApi.listDecks()
setDecks(d)
} catch { /* ignore */ }
setLoading(false)
}
useEffect(() => { fetchDecks() }, [])
async function handleCreate() {
if (!newDeckName.trim()) return
setCreating(true)
try {
const deck = await decksApi.createDeck({ name: newDeckName.trim(), commander: newCommander.trim() || undefined })
setNewDeckName('')
setNewCommander('')
setShowCreate(false)
setSelectedDeck(deck)
fetchDecks()
} catch { /* ignore */ }
setCreating(false)
}
async function handleDelete(deckId: number) {
try {
await decksApi.deleteDeck(deckId)
fetchDecks()
} catch { /* ignore */ }
}
if (selectedDeck) {
return (
<div className="p-6">
<DeckDetail deck={selectedDeck} onBack={() => { setSelectedDeck(null); fetchDecks() }} />
</div>
)
}
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Decks</h1>
<p className="text-sm text-text-secondary mt-0.5">{decks.length} deck{decks.length !== 1 ? 's' : ''}</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors"
>
<Plus className="w-4 h-4" />
New Deck
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 animate-fade-in space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">Create New Deck</h3>
<button onClick={() => setShowCreate(false)} className="text-text-tertiary hover:text-text-secondary">
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Deck name"
value={newDeckName}
onChange={e => setNewDeckName(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Commander (optional)"
value={newCommander}
onChange={e => setNewCommander(e.target.value)}
className="px-4 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<button onClick={handleCreate} disabled={creating}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors disabled:opacity-50">
{creating ? <Spinner size="sm" /> : 'Create'}
</button>
</div>
)}
{/* Deck list */}
{loading ? (
<div className="flex items-center justify-center h-40"><Spinner size="lg" className="text-accent-blue" /></div>
) : decks.length === 0 ? (
<EmptyState icon={Layers} title="No decks yet" description="Create your first deck to get started." />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{decks.map(deck => (
<div
key={deck.id}
className="bg-bg-secondary rounded-xl border border-border-primary hover:border-border-secondary p-5 cursor-pointer transition-all group"
onClick={() => setSelectedDeck(deck)}
>
<div className="flex items-start justify-between mb-3">
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-text-primary truncate group-hover:text-accent-blue transition-colors">{deck.name}</h3>
<p className="text-sm text-text-secondary mt-0.5 truncate">{deck.commander}</p>
</div>
<button
onClick={e => { e.stopPropagation(); handleDelete(deck.id) }}
className="opacity-0 group-hover:opacity-100 p-1.5 text-text-tertiary hover:text-accent-red transition-all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ColorDots colors={deck.color_identity} />
<span className="text-xs text-text-tertiary">{deck.card_count} cards</span>
</div>
<span className="text-xs font-medium text-accent-teal">${deck.total_price.toFixed(2)}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -1,284 +0,0 @@
import { useState, useEffect } from 'react'
import {
Gamepad2, Play, Wifi, WifiOff, Settings,
AlertCircle, Trophy, Clock, Brain
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { simApi, labApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type { SimStatus, SimResult, DeepSeekStatus, LabDeck } from '../types'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
export function SimulatorPage() {
const [decks, setDecks] = useState<LabDeck[]>([])
const [deckA, setDeckA] = useState('')
const [deckB, setDeckB] = useState('')
const [games, setGames] = useState(50)
const [useDeepSeek, setUseDeepSeek] = useState(false)
const [deepSeekDeck, setDeepSeekDeck] = useState('')
const [dsStatus, setDsStatus] = useState<DeepSeekStatus | null>(null)
const [running, setRunning] = useState(false)
const [simId, setSimId] = useState<string | null>(null)
const [simStatus, setSimStatus] = useState<SimStatus | null>(null)
const [result, setResult] = useState<SimResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [starting, setStarting] = useState(false)
// DS config
const [showDsConfig, setShowDsConfig] = useState(false)
const [dsModel, setDsModel] = useState('')
const [dsUrl, setDsUrl] = useState('')
useEffect(() => {
labApi.getLabDecks().then(setDecks).catch(() => {})
simApi.getDeepSeekStatus().then(s => {
setDsStatus(s)
if (s.model) setDsModel(s.model)
if (s.base_url) setDsUrl(s.base_url)
}).catch(() => {})
}, [])
// Poll while running
usePolling(async () => {
if (!simId) return
try {
const s = await simApi.getSimStatus(simId)
setSimStatus(s)
if (!s.running) {
const r = await simApi.getSimResult(simId)
setResult(r)
setRunning(false)
}
} catch { /* ignore */ }
}, 2000, running)
async function handleStart() {
if (!deckA || !deckB) { setError('Select both decks'); return }
setStarting(true)
setError(null)
setResult(null)
try {
let res
if (useDeepSeek && deepSeekDeck) {
res = await simApi.runDeepSeekSim({ deck_a: deckA, deck_b: deckB, games, deepseek_deck: deepSeekDeck })
} else {
res = await simApi.runSim({ deck_a: deckA, deck_b: deckB, games })
}
setSimId(res.sim_id)
setRunning(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
}
setStarting(false)
}
async function handleDsConnect() {
try {
const s = await simApi.connectDeepSeek()
setDsStatus(s)
} catch { /* ignore */ }
}
async function handleDsConfigure() {
try {
const s = await simApi.configureDeepSeek({
model: dsModel || undefined,
base_url: dsUrl || undefined,
})
setDsStatus(s)
setShowDsConfig(false)
} catch { /* ignore */ }
}
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Gamepad2 className="w-6 h-6 text-accent-purple" />
Simulator
</h1>
<p className="text-sm text-text-secondary mt-0.5">Run head-to-head deck simulations</p>
</div>
{/* DeepSeek status */}
<div className="flex items-center gap-3">
<StatusBadge
variant={dsStatus?.connected ? 'success' : 'warning'}
label={dsStatus?.connected ? `DeepSeek: ${dsStatus.model}` : 'DeepSeek: Disconnected'}
/>
<button onClick={handleDsConnect}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
{dsStatus?.connected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{dsStatus?.connected ? 'Reconnect' : 'Connect'}
</button>
<button onClick={() => setShowDsConfig(!showDsConfig)}
className="flex items-center gap-1.5 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
Configure
</button>
</div>
{showDsConfig && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-4 max-w-md animate-fade-in space-y-3">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Brain className="w-4 h-4 text-accent-purple" />
DeepSeek Configuration
</h3>
<input
type="text"
placeholder="Model name"
value={dsModel}
onChange={e => setDsModel(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<input
type="text"
placeholder="Base URL (e.g. http://192.168.0.240:11434)"
value={dsUrl}
onChange={e => setDsUrl(e.target.value)}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
<button onClick={handleDsConfigure}
className="px-4 py-2 bg-accent-blue text-white text-sm font-medium rounded-lg hover:bg-accent-blue-hover transition-colors">
Save
</button>
</div>
)}
{/* Sim config */}
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck A</label>
<select
value={deckA}
onChange={e => setDeckA(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Deck B</label>
<select
value={deckB}
onChange={e => setDeckB(e.target.value)}
className="mt-1 w-full px-3 py-2.5 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">Select deck...</option>
{decks.map(d => <option key={d.name} value={d.name}>{d.name}</option>)}
</select>
</div>
</div>
<div className="flex items-center gap-6">
<div>
<label className="text-xs text-text-tertiary uppercase tracking-wider">Games</label>
<input
type="number" min={1} max={1000}
value={games}
onChange={e => setGames(Number(e.target.value))}
className="mt-1 w-24 px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-5">
<input type="checkbox" checked={useDeepSeek} onChange={e => setUseDeepSeek(e.target.checked)}
className="w-4 h-4 rounded border-border-primary bg-bg-tertiary text-accent-purple focus:ring-accent-purple/30" />
<span className="text-sm text-text-secondary">Use DeepSeek AI pilot</span>
</label>
{useDeepSeek && (
<div className="mt-5">
<select
value={deepSeekDeck}
onChange={e => setDeepSeekDeck(e.target.value)}
className="px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all"
>
<option value="">AI pilots which deck?</option>
{[deckA, deckB].filter(Boolean).map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
)}
</div>
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-status-error/10 border border-status-error/30 rounded-lg text-sm text-status-error">
<AlertCircle className="w-4 h-4" />{error}
</div>
)}
{running ? (
<div className="flex items-center gap-3">
<Spinner size="md" className="text-accent-blue" />
<div>
<p className="text-sm text-text-primary">Simulation running...</p>
{simStatus && (
<p className="text-xs text-text-tertiary">
{simStatus.games_completed ?? 0} / {simStatus.total_games ?? games} games
</p>
)}
</div>
</div>
) : (
<button onClick={handleStart} disabled={starting || !deckA || !deckB}
className="flex items-center gap-2 px-5 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-semibold rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-50">
{starting ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Run Simulation
</button>
)}
</div>
{/* Results */}
{result && (
<div className="bg-bg-secondary rounded-xl border border-border-primary p-5 space-y-4 animate-fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<Trophy className="w-4 h-4 text-accent-amber" />
Results
</h3>
<span className="text-xs text-text-tertiary flex items-center gap-1.5">
<Clock className="w-3 h-3" />
{(result.duration_ms / 1000).toFixed(1)}s · {result.total_games} games
</span>
</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={result.decks.map(d => ({ name: d.name, win_rate: Math.round(d.win_rate * 100) }))} barSize={40}>
<XAxis dataKey="name" tick={{ fill: '#9ba1b8', fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: '#6b7190', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ backgroundColor: '#1c2030', border: '1px solid #2a2f42', borderRadius: 8, fontSize: 12 }}
formatter={(value: unknown) => [`${value}%`, 'Win Rate']} />
<Bar dataKey="win_rate" radius={[4, 4, 0, 0]}>
{result.decks.map((d, i) => (
<Cell key={i} fill={d.win_rate === Math.max(...result.decks.map(x => x.win_rate)) ? '#4ade80' : '#4f6ef7'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 gap-4">
{result.decks.map(d => (
<div key={d.name} className="px-4 py-3 rounded-lg bg-bg-tertiary">
<p className="text-sm font-semibold text-text-primary">{d.name}</p>
<div className="grid grid-cols-3 gap-2 mt-2">
<div>
<p className="text-[10px] text-text-tertiary uppercase">Wins</p>
<p className="text-lg font-bold text-accent-green">{d.wins}</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Win Rate</p>
<p className="text-lg font-bold text-accent-blue">{(d.win_rate * 100).toFixed(1)}%</p>
</div>
<div>
<p className="text-[10px] text-text-tertiary uppercase">Avg Turn</p>
<p className="text-lg font-bold text-text-primary">{d.avg_turn.toFixed(1)}</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -1,822 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import {
Brain, Play, RotateCw, Database, Trophy, AlertCircle,
Activity, Zap, Swords, FileText, Cpu, CheckCircle,
ChevronDown, ChevronUp, Settings
} from 'lucide-react'
import { Spinner, StatusBadge } from '../components/common'
import { trainingApi } from '../api'
import { usePolling } from '../hooks/usePolling'
import type {
MLStatus, MLDataStatus, MLTrainStatus, PPOTrainStatus,
TournamentStatus, MLModelInfo
} from '../types'
// ── Progress Bar ──────────────────────────────────────────────
function ProgressBar({ value, max, label, color = 'bg-accent-blue' }: { value: number; max: number; label: string; color?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
return (
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-text-secondary">{label}</span>
<span className="text-text-tertiary">{value} / {max} ({pct.toFixed(0)}%)</span>
</div>
<div className="w-full h-2 bg-bg-tertiary rounded-full overflow-hidden">
<div className={'h-full rounded-full transition-all duration-500 ' + color} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
// ── Phase Badge ──────────────────────────────────────────────
const PHASE_STYLES: Record<string, string> = {
idle: 'bg-bg-tertiary text-text-tertiary',
starting: 'bg-accent-blue/15 text-accent-blue animate-pulse',
building: 'bg-accent-purple/15 text-accent-purple animate-pulse',
training: 'bg-accent-blue/15 text-accent-blue animate-pulse',
evaluating: 'bg-accent-teal/15 text-accent-teal animate-pulse',
running: 'bg-accent-amber/15 text-accent-amber animate-pulse',
done: 'bg-accent-green/15 text-accent-green',
error: 'bg-accent-red/15 text-accent-red',
}
function PhaseBadge({ phase }: { phase: string }) {
const cls = PHASE_STYLES[phase] || PHASE_STYLES.idle
return (
<span className={'text-[10px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded ' + cls}>
{phase}
</span>
)
}
// ── Metric Card ──────────────────────────────────────────────
function MetricCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
return (
<div className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary">{label}</p>
<p className={'text-sm font-bold ' + (accent || 'text-text-primary')}>{value}</p>
</div>
)
}
// ── Collapsible Section ─────────────────────────────────────
function Section({ title, icon, children, defaultOpen = true, color = 'text-accent-blue' }: {
title: string; icon: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; color?: string
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="bg-bg-secondary rounded-xl border border-border-primary overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-5 py-4 hover:bg-bg-hover transition-colors"
>
<h3 className="text-sm font-semibold text-text-primary flex items-center gap-2">
<span className={color}>{icon}</span>
{title}
</h3>
{open ? <ChevronUp className="w-4 h-4 text-text-tertiary" /> : <ChevronDown className="w-4 h-4 text-text-tertiary" />}
</button>
{open && <div className="px-5 pb-5 space-y-4 border-t border-border-primary pt-4">{children}</div>}
</div>
)
}
// ══════════════════════════════════════════════════════════════
// Main Training Page
// ══════════════════════════════════════════════════════════════
export function TrainingPage() {
// Status states
const [mlStatus, setMlStatus] = useState<MLStatus | null>(null)
const [dataStatus, setDataStatus] = useState<MLDataStatus | null>(null)
const [modelInfo, setModelInfo] = useState<MLModelInfo | null>(null)
const [trainStatus, setTrainStatus] = useState<MLTrainStatus | null>(null)
const [ppoStatus, setPpoStatus] = useState<PPOTrainStatus | null>(null)
const [tournStatus, setTournStatus] = useState<TournamentStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Supervised training config
const [epochs, setEpochs] = useState(50)
const [lr, setLr] = useState(0.001)
const [batchSize, setBatchSize] = useState(256)
const [patience, setPatience] = useState(10)
const [rebuildDataset, setRebuildDataset] = useState(true)
// PPO config
const [ppoIterations, setPpoIterations] = useState(100)
const [ppoEpisodes, setPpoEpisodes] = useState(64)
const [ppoEpochs, setPpoEpochs] = useState(4)
const [ppoBatchSize, setPpoBatchSize] = useState(256)
const [ppoLr, setPpoLr] = useState(0.0003)
const [ppoClipEps, setPpoClipEps] = useState(0.2)
const [ppoEntropy, setPpoEntropy] = useState(0.01)
const [ppoOpponent, setPpoOpponent] = useState('heuristic')
const [ppoPlaystyle, _setPpoPlaystyle] = useState('midrange') // eslint-disable-line
// Tournament config
const [tournEpisodes, setTournEpisodes] = useState(50)
const [tournPlaystyle, setTournPlaystyle] = useState('midrange')
// Action states
const [startingTrain, setStartingTrain] = useState(false)
const [startingPpo, setStartingPpo] = useState(false)
const [startingTourn, setStartingTourn] = useState(false)
const [reloading, setReloading] = useState(false)
const [showSupAdvanced, setShowSupAdvanced] = useState(false)
const [showPpoAdvanced, setShowPpoAdvanced] = useState(false)
// ── Initial Load ──────────────────────────────────────────
const loadAll = useCallback(async () => {
try {
const [ml, data, model, train, ppo, tourn] = await Promise.all([
trainingApi.getMLStatus().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
trainingApi.getModelInfo().catch(() => null),
trainingApi.getTrainStatus().catch(() => null),
trainingApi.getPPOStatus().catch(() => null),
trainingApi.getTournamentStatus().catch(() => null),
])
if (ml) setMlStatus(ml)
if (data) setDataStatus(data)
if (model) setModelInfo(model)
if (train) setTrainStatus(train)
if (ppo) setPpoStatus(ppo)
if (tourn) setTournStatus(tourn)
} catch { /* ignore */ }
setLoading(false)
}, [])
useEffect(() => { loadAll() }, [loadAll])
// ── Polling ───────────────────────────────────────────────
const isTraining = trainStatus?.running || false
const isPpo = ppoStatus?.running || false
const isTourn = tournStatus?.running || false
const anyRunning = isTraining || isPpo || isTourn
usePolling(async () => {
try {
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
if (!s.running && s.phase === 'done') {
// Refresh model info + data when training completes
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isTraining)
usePolling(async () => {
try {
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
if (!s.running && s.phase === 'done') {
const [model, data] = await Promise.all([
trainingApi.getModelInfo().catch(() => null),
trainingApi.getDataStatus().catch(() => null),
])
if (model) setModelInfo(model)
if (data) setDataStatus(data)
}
} catch { /* ignore */ }
}, 2000, isPpo)
usePolling(async () => {
try {
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch { /* ignore */ }
}, 2000, isTourn)
// ── Actions ───────────────────────────────────────────────
async function handleToggleLogging() {
try {
const newState = !(mlStatus?.ml_logging_enabled ?? false)
await trainingApi.toggleML(newState)
const ml = await trainingApi.getMLStatus()
setMlStatus(ml)
} catch { /* ignore */ }
}
async function handleReloadModel() {
setReloading(true)
try {
const res = await trainingApi.reloadModel()
setModelInfo(res.status)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to reload model')
}
setReloading(false)
}
async function handleStartTrain() {
setStartingTrain(true)
setError(null)
try {
await trainingApi.startTraining({ epochs, lr, batchSize, patience, rebuildDataset })
const s = await trainingApi.getTrainStatus()
setTrainStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start training')
}
setStartingTrain(false)
}
async function handleStartPpo() {
setStartingPpo(true)
setError(null)
try {
await trainingApi.startPPOTraining({
iterations: ppoIterations,
episodesPerIter: ppoEpisodes,
ppoEpochs,
batchSize: ppoBatchSize,
lr: ppoLr,
clipEpsilon: ppoClipEps,
entropyCoeff: ppoEntropy,
opponent: ppoOpponent,
playstyle: ppoPlaystyle,
})
const s = await trainingApi.getPPOStatus()
setPpoStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start PPO training')
}
setStartingPpo(false)
}
async function handleStartTournament() {
setStartingTourn(true)
setError(null)
try {
await trainingApi.startTournament({ episodes: tournEpisodes, playstyle: tournPlaystyle })
const s = await trainingApi.getTournamentStatus()
setTournStatus(s)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start tournament')
}
setStartingTourn(false)
}
// ── Render ────────────────────────────────────────────────
if (loading) {
return <div className="p-6 flex items-center justify-center h-64"><Spinner size="lg" className="text-accent-blue" /></div>
}
const trainDataset = dataStatus?.datasets?.['train']
const hasDecisions = (mlStatus?.total_decisions ?? 0) > 0
const hasModel = modelInfo?.loaded === true
return (
<div className="p-6 space-y-5">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-3">
<Brain className="w-6 h-6 text-accent-green" />
ML Training Pipeline
</h1>
<p className="text-sm text-text-secondary mt-0.5">Collect data, train models, and evaluate performance</p>
</div>
{/* Error banner */}
{error && (
<div className="flex items-center gap-2 px-4 py-2 bg-accent-red/10 border border-accent-red/30 rounded-lg text-sm text-accent-red">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1">{error}</span>
<button onClick={() => setError(null)} className="text-accent-red/60 hover:text-accent-red text-xs">dismiss</button>
</div>
)}
{/* Pipeline overview bar */}
<div className="flex flex-wrap items-center gap-3 bg-bg-secondary rounded-xl border border-border-primary px-5 py-3">
<div className="flex items-center gap-2">
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
<span className="text-xs text-text-secondary">Logging {mlStatus?.ml_logging_enabled ? 'On' : 'Off'}</span>
</div>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{mlStatus?.total_decisions ?? 0} decisions</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{trainDataset ? `${trainDataset.samples.toLocaleString()} training samples` : 'No dataset'}</span>
<span className="text-border-primary">|</span>
<span className="text-xs text-text-secondary">{(dataStatus?.checkpoints?.length ?? 0)} checkpoints</span>
<span className="text-border-primary">|</span>
<StatusBadge
variant={hasModel ? 'success' : 'neutral'}
label={hasModel ? `Model on ${modelInfo?.device ?? '?'}` : 'No model'}
/>
{anyRunning && (
<>
<span className="text-border-primary">|</span>
<span className="text-xs font-medium text-accent-blue animate-pulse">Pipeline running...</span>
</>
)}
</div>
{/* ═══ Phase 1: Data Collection ═══ */}
<Section title="Phase 1 — Data Collection" icon={<FileText className="w-4 h-4" />} color="text-accent-teal">
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">
Run batch simulations with ML logging enabled to collect decision data.
</p>
<button
onClick={handleToggleLogging}
className={'flex items-center gap-2 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ' +
(mlStatus?.ml_logging_enabled
? 'bg-accent-green/15 text-accent-green border-accent-green/30 hover:bg-accent-green/25'
: 'bg-bg-tertiary text-text-secondary border-border-primary hover:bg-bg-hover'
)}
>
<div className={'w-2 h-2 rounded-full ' + (mlStatus?.ml_logging_enabled ? 'bg-accent-green' : 'bg-text-tertiary')} />
{mlStatus?.ml_logging_enabled ? 'Logging Enabled' : 'Enable Logging'}
</button>
</div>
{/* Decision files */}
{(dataStatus?.decisionFiles?.length ?? 0) > 0 ? (
<div className="space-y-1">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Decision Files</h4>
<div className="max-h-36 overflow-y-auto space-y-1">
{dataStatus!.decisionFiles.map(f => (
<div key={f.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{f.name}</span>
<div className="flex items-center gap-3 text-text-tertiary">
<span>{f.decisions.toLocaleString()} decisions</span>
<span>{(f.size / 1024).toFixed(0)} KB</span>
</div>
</div>
))}
</div>
</div>
) : (
<div className="text-xs text-text-tertiary italic">No decision files yet. Run batch sims with logging enabled.</div>
)}
</Section>
{/* ═══ Phase 2: Dataset & Checkpoints ═══ */}
<Section title="Phase 2 — Dataset & Model Artifacts" icon={<Database className="w-4 h-4" />} color="text-accent-purple">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Dataset splits */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Dataset Splits</h4>
{Object.keys(dataStatus?.datasets ?? {}).length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{(['train', 'val', 'test'] as const).map(split => {
const ds = dataStatus?.datasets?.[split]
return (
<div key={split} className="px-3 py-2 bg-bg-tertiary rounded-lg">
<p className="text-[10px] text-text-tertiary uppercase">{split}</p>
{ds ? (
<>
<p className="text-sm font-bold text-text-primary">{ds.samples.toLocaleString()}</p>
<p className="text-[10px] text-text-tertiary">{ds.features} features</p>
</>
) : (
<p className="text-xs text-text-tertiary italic">Not built</p>
)}
</div>
)
})}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No dataset built yet. Training will build it automatically.</p>
)}
</div>
{/* Checkpoints */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Checkpoints</h4>
{(dataStatus?.checkpoints?.length ?? 0) > 0 ? (
<div className="max-h-28 overflow-y-auto space-y-1">
{dataStatus!.checkpoints.map(ckpt => (
<div key={ckpt.name} className="flex items-center justify-between px-3 py-1.5 bg-bg-tertiary rounded-lg text-xs">
<span className="text-text-secondary font-mono truncate">{ckpt.name}</span>
<span className="text-text-tertiary">{(ckpt.size / 1024 / 1024).toFixed(1)} MB</span>
</div>
))}
</div>
) : (
<p className="text-xs text-text-tertiary italic">No checkpoints yet. Train a model to generate them.</p>
)}
</div>
</div>
{/* Model info + reload */}
<div className="flex items-center justify-between pt-2 border-t border-border-primary">
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-text-tertiary" />
<div className="text-xs">
{hasModel ? (
<span className="text-accent-green font-medium">
Model loaded on {modelInfo?.device} | {modelInfo?.input_dim} features | {modelInfo?.num_actions} actions
</span>
) : (
<span className="text-text-tertiary">No model loaded{modelInfo?.error ? ` — ${modelInfo.error}` : ''}</span>
)}
</div>
</div>
<button
onClick={handleReloadModel}
disabled={reloading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-bg-tertiary text-text-secondary rounded-lg hover:bg-bg-hover border border-border-primary transition-colors disabled:opacity-50"
>
{reloading ? <Spinner size="sm" /> : <RotateCw className="w-3 h-3" />}
Reload Model
</button>
</div>
{/* Eval results */}
{dataStatus?.evalResults && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
Latest Evaluation
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(dataStatus.evalResults).map(([k, v]) => (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={typeof v === 'number' ? (k.includes('accuracy') || k.includes('rate') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)) : String(v)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined}
/>
))}
</div>
</div>
)}
</Section>
{/* ═══ Phase 3: Supervised Training ═══ */}
<Section title="Phase 3 — Supervised Training" icon={<Activity className="w-4 h-4" />}>
{/* Config */}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Epochs</label>
<input type="number" min={1} max={1000} value={epochs} onChange={e => setEpochs(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.00001} max={1} step={0.0001} value={lr} onChange={e => setLr(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
{/* Advanced toggle */}
<button onClick={() => setShowSupAdvanced(!showSupAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showSupAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showSupAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={batchSize} onChange={e => setBatchSize(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Early Stop Patience</label>
<input type="number" min={1} max={100} value={patience} onChange={e => setPatience(Number(e.target.value))}
disabled={isTraining}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-xs text-text-secondary cursor-pointer select-none">
<input type="checkbox" checked={rebuildDataset} onChange={e => setRebuildDataset(e.target.checked)}
disabled={isTraining}
className="rounded border-border-primary bg-bg-tertiary text-accent-blue focus:ring-accent-blue/30 w-3.5 h-3.5" />
Rebuild dataset
</label>
</div>
</div>
)}
</div>
{/* Training status / start button */}
{isTraining || (trainStatus?.phase && trainStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PhaseBadge phase={trainStatus!.phase} />
<span className="text-xs text-text-secondary">{trainStatus!.message}</span>
</div>
{trainStatus!.started_at && (
<span className="text-[10px] text-text-tertiary">Started: {new Date(trainStatus!.started_at).toLocaleTimeString()}</span>
)}
</div>
{/* Progress bar — only in training phase */}
{(trainStatus!.phase === 'training' || trainStatus!.phase === 'done') && trainStatus!.total_epochs > 0 && (
<ProgressBar
value={trainStatus!.current_epoch}
max={trainStatus!.total_epochs}
label="Epoch progress"
/>
)}
{/* Live metrics during training */}
{trainStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(trainStatus!.metrics).map(([k, v]) => {
if (typeof v !== 'number') return null
const isAcc = k.includes('accuracy') || k.includes('acc')
const isLoss = k.includes('loss')
return (
<MetricCard
key={k}
label={k.replace(/_/g, ' ')}
value={isAcc ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={isAcc ? 'text-accent-green' : isLoss ? 'text-accent-amber' : undefined}
/>
)
})}
</div>
)}
{/* Final result */}
{trainStatus!.phase === 'done' && trainStatus!.result && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-accent-green flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5" /> Training Complete
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<MetricCard label="Device" value={trainStatus!.result.device} />
<MetricCard label="Checkpoint" value={trainStatus!.result.checkpoint.split('/').pop() || '—'} />
{trainStatus!.result.evaluation && Object.entries(trainStatus!.result.evaluation).map(([k, v]) => (
typeof v === 'number' ? (
<MetricCard key={k} label={k.replace(/_/g, ' ')}
value={k.includes('accuracy') ? `${(v * 100).toFixed(1)}%` : v.toFixed(4)}
accent={k.includes('accuracy') ? 'text-accent-green' : undefined} />
) : null
))}
</div>
</div>
)}
{/* Error state */}
{trainStatus!.phase === 'error' && trainStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{trainStatus!.error}</span>
</div>
)}
</div>
) : (
<div className="flex items-center gap-3">
<button
onClick={handleStartTrain}
disabled={startingTrain || !hasDecisions || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-blue/15 text-accent-blue text-sm font-medium rounded-lg hover:bg-accent-blue/25 border border-accent-blue/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTrain ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start Training
</button>
{!hasDecisions && (
<span className="text-xs text-text-tertiary italic">Enable logging and run batch sims first to generate decision data.</span>
)}
</div>
)}
</Section>
{/* ═══ Phase 4: PPO Reinforcement Learning ═══ */}
<Section title="Phase 4 — PPO Reinforcement Learning" icon={<Zap className="w-4 h-4" />} color="text-accent-purple" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Fine-tune the supervised model using PPO self-play against an opponent policy.
</p>
{/* Config */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Iterations</label>
<input type="number" min={1} max={1000} value={ppoIterations} onChange={e => setPpoIterations(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Opponent</label>
<select value={ppoOpponent} onChange={e => setPpoOpponent(e.target.value)}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="heuristic">Heuristic</option>
<option value="random">Random</option>
<option value="self">Self-Play</option>
</select>
</div>
</div>
<button onClick={() => setShowPpoAdvanced(!showPpoAdvanced)} className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors">
<Settings className="w-3 h-3" />
{showPpoAdvanced ? 'Hide' : 'Show'} advanced options
</button>
{showPpoAdvanced && (
<div className="grid grid-cols-3 gap-3 animate-fade-in">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes / Iter</label>
<input type="number" min={1} max={512} value={ppoEpisodes} onChange={e => setPpoEpisodes(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">PPO Epochs</label>
<input type="number" min={1} max={20} value={ppoEpochs} onChange={e => setPpoEpochs(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Batch Size</label>
<input type="number" min={16} max={2048} value={ppoBatchSize} onChange={e => setPpoBatchSize(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Learning Rate</label>
<input type="number" min={0.000001} max={0.01} step={0.0001} value={ppoLr} onChange={e => setPpoLr(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Clip Epsilon</label>
<input type="number" min={0.05} max={0.5} step={0.05} value={ppoClipEps} onChange={e => setPpoClipEps(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Entropy Coeff</label>
<input type="number" min={0} max={0.1} step={0.005} value={ppoEntropy} onChange={e => setPpoEntropy(Number(e.target.value))}
disabled={isPpo}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
</div>
)}
{/* PPO running status */}
{isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={ppoStatus!.phase} />
<span className="text-xs text-text-secondary">{ppoStatus!.message}</span>
</div>
{(ppoStatus!.phase === 'training' || ppoStatus!.phase === 'done') && ppoStatus!.total_iterations > 0 && (
<ProgressBar
value={ppoStatus!.iteration}
max={ppoStatus!.total_iterations}
label="PPO iteration"
color="bg-accent-purple"
/>
)}
{ppoStatus!.metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{ppoStatus!.metrics.win_rate != null && (
<MetricCard label="Win Rate" value={`${(ppoStatus!.metrics.win_rate * 100).toFixed(1)}%`} accent="text-accent-green" />
)}
{ppoStatus!.metrics.avg_reward != null && (
<MetricCard label="Avg Reward" value={ppoStatus!.metrics.avg_reward.toFixed(3)} accent="text-accent-amber" />
)}
{ppoStatus!.metrics.policy_loss != null && (
<MetricCard label="Policy Loss" value={ppoStatus!.metrics.policy_loss.toFixed(4)} />
)}
{ppoStatus!.metrics.entropy != null && (
<MetricCard label="Entropy" value={ppoStatus!.metrics.entropy.toFixed(4)} />
)}
</div>
)}
{ppoStatus!.phase === 'done' && ppoStatus!.result && (
<div className="flex items-center gap-2 pt-2 border-t border-border-primary">
<CheckCircle className="w-3.5 h-3.5 text-accent-green" />
<span className="text-xs text-accent-green font-medium">
PPO Training Complete — Best Win Rate: {
typeof ppoStatus!.result.best_win_rate === 'number'
? `${(ppoStatus!.result.best_win_rate as number * 100).toFixed(1)}%`
: '—'
}
</span>
</div>
)}
{ppoStatus!.phase === 'error' && ppoStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{ppoStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartPpo}
disabled={startingPpo || isTraining || !hasModel}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-purple/15 text-accent-purple text-sm font-medium rounded-lg hover:bg-accent-purple/25 border border-accent-purple/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingPpo ? <Spinner size="sm" /> : <Play className="w-4 h-4" />}
Start PPO Training
</button>
)}
{!hasModel && !(isPpo || (ppoStatus?.phase && ppoStatus.phase !== 'idle')) && (
<p className="text-xs text-text-tertiary italic">Train a supervised model first (Phase 3) before running PPO.</p>
)}
</Section>
{/* ═══ Phase 5: Tournament Evaluation ═══ */}
<Section title="Phase 5 — Tournament Evaluation" icon={<Swords className="w-4 h-4" />} color="text-accent-amber" defaultOpen={false}>
<p className="text-xs text-text-secondary">
Pit the trained model against heuristic, random, and other policy variants.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-tertiary block mb-1">Episodes per Matchup</label>
<input type="number" min={10} max={1000} value={tournEpisodes} onChange={e => setTournEpisodes(Number(e.target.value))}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50" />
</div>
<div>
<label className="text-xs text-text-tertiary block mb-1">Playstyle</label>
<select value={tournPlaystyle} onChange={e => setTournPlaystyle(e.target.value)}
disabled={isTourn}
className="w-full px-3 py-2 bg-bg-tertiary border border-border-primary rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent-blue/50 transition-all disabled:opacity-50">
<option value="midrange">Midrange</option>
<option value="aggro">Aggro</option>
<option value="control">Control</option>
<option value="combo">Combo</option>
</select>
</div>
</div>
{/* Tournament status */}
{isTourn || (tournStatus?.phase && tournStatus.phase !== 'idle') ? (
<div className="space-y-3 bg-bg-tertiary/50 rounded-xl p-4 border border-border-primary">
<div className="flex items-center gap-2">
<PhaseBadge phase={tournStatus!.phase} />
<span className="text-xs text-text-secondary">{tournStatus!.message}</span>
</div>
{tournStatus!.phase === 'error' && tournStatus!.error && (
<div className="flex items-start gap-2 bg-accent-red/10 border border-accent-red/20 rounded-lg px-3 py-2">
<AlertCircle className="w-4 h-4 text-accent-red mt-0.5 flex-shrink-0" />
<span className="text-xs text-accent-red">{tournStatus!.error}</span>
</div>
)}
</div>
) : (
<button
onClick={handleStartTournament}
disabled={startingTourn || isTraining || isPpo}
className="flex items-center gap-2 px-4 py-2.5 bg-accent-amber/15 text-accent-amber text-sm font-medium rounded-lg hover:bg-accent-amber/25 border border-accent-amber/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{startingTourn ? <Spinner size="sm" /> : <Trophy className="w-4 h-4" />}
Start Tournament
</button>
)}
{/* Tournament results */}
{(tournStatus?.result?.players?.length ?? 0) > 0 && (
<div className="pt-2 border-t border-border-primary space-y-2">
<h4 className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex items-center gap-2">
<Trophy className="w-3.5 h-3.5 text-accent-amber" />
Leaderboard
</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-primary">
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium w-8">#</th>
<th className="px-3 py-2 text-left text-xs text-text-tertiary font-medium">Policy</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Wins</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Games</th>
<th className="px-3 py-2 text-right text-xs text-text-tertiary font-medium">Win Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-border-primary">
{tournStatus!.result!.players
.sort((a, b) => b.win_rate - a.win_rate)
.map((p, i) => (
<tr key={p.name} className="hover:bg-bg-hover transition-colors">
<td className="px-3 py-2 text-text-tertiary text-xs">
{i === 0 ? <Trophy className="w-3.5 h-3.5 text-accent-amber" /> : i + 1}
</td>
<td className="px-3 py-2">
<span className="text-text-primary font-medium capitalize">{p.name}</span>
</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.wins}</td>
<td className="px-3 py-2 text-right text-text-secondary">{p.games}</td>
<td className={'px-3 py-2 text-right font-semibold ' + (i === 0 ? 'text-accent-green' : 'text-accent-blue')}>
{(p.win_rate * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
</div>
)
}
@@ -1,562 +0,0 @@
// ═══════════════════════════════════════════════════════════
// Commander AI Lab — TypeScript Type Definitions
// Derived from the FastAPI backend's Pydantic models + SQLite schema
// ═══════════════════════════════════════════════════════════
// ── Cards & Collection ──────────────────────────────────────
export interface CollectionCard {
id: number
name: string
scryfall_id: string
set_code: string
set_name: string
collector_number: string
rarity: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
color_identity: string[]
quantity: number
foil_quantity: number
condition: string
tcg_price: number | null
foil_price: number | null
image_url: string
edhrec_rank: number | null
keywords: string
added_date: string
updated_date: string
}
export interface CollectionFilters {
q?: string
type_line?: string
color?: string
rarity?: string
set_code?: string
cmc_min?: number
cmc_max?: number
price_min?: number
price_max?: number
owned_only?: boolean
sort_by?: string
sort_dir?: 'asc' | 'desc'
page?: number
page_size?: number
}
export interface CollectionStats {
total_cards: number
total_unique: number
total_value: number
sets_count: number
}
export interface SetInfo {
code: string
name: string
card_count?: number
}
// ── Scan ─────────────────────────────────────────────────────
export interface ScanResult {
matches: ScanMatch[]
image_url?: string
}
export interface ScanMatch {
name: string
set_code: string
set_name: string
collector_number: string
scryfall_id: string
confidence: number
image_url: string
type_line: string
oracle_text: string
mana_cost: string
tcg_price: number | null
}
// ── Decks ────────────────────────────────────────────────────
export interface Deck {
id: number
name: string
commander: string
commander_scryfall_id?: string
commander_image_url?: string
color_identity: string[]
format: string
card_count: number
total_price: number
created_date: string
updated_date: string
}
export interface DeckCard {
id: number
deck_id: number
name: string
scryfall_id: string
quantity: number
is_commander: boolean
category: string
type_line: string
mana_cost: string
cmc: number
oracle_text: string
tcg_price: number | null
image_url: string
owned_qty: number
set_code: string
}
export interface DeckAnalysis {
card_count: number
land_count: number
creature_count: number
noncreature_count: number
avg_cmc: number
mana_curve: Record<number, number>
color_distribution: Record<string, number>
type_distribution: Record<string, number>
total_price: number
owned_count: number
missing_count: number
}
export interface DeckRecommendation {
name: string
scryfall_id: string
type_line: string
mana_cost: string
oracle_text: string
image_url: string
tcg_price: number | null
reason: string
score: number
owned: boolean
}
// ── EDHREC Recommendations ──────────────────────────────────
export interface EdhRecCard {
name: string
type_line: string
role: string
roles: string[]
inclusion_pct: number | null
synergy_score: number | null
owned: boolean
owned_qty: number
scryfall_id: string
image_url: string | null
}
export interface EdhRecsResponse {
commander: string
source: string
total: number
recommendations: EdhRecCard[]
}
// ── Collection Recommendations ──────────────────────────────
export interface CollectionRecCard {
id: number
scryfall_id: string
name: string
type_line: string
card_type: string
cmc: number
color_identity: string[]
owned_qty: number
roles: string[]
score: number
image_url: string | null
}
export interface CollectionRecsResponse {
shortfall_types: string[]
role_filter: string[]
grouped: Record<string, CollectionRecCard[]>
total: number
}
// ── V3 Deck Generator ────────────────────────────────────────
export interface DeckGenV3Request {
commander_name: string
strategy?: string
target_bracket: number
budget_usd?: number
budget_mode?: 'total' | 'per_card'
omit_cards?: string[]
use_collection?: boolean
run_substitution?: boolean
model?: string
deck_name?: string
}
export interface DeckGenV3Status {
initialized: boolean
pplx_configured: boolean
model: string | null
embeddings_loaded: boolean
embedding_cards: number
error: string | null
}
export interface DeckGenV3Card {
name: string
quantity: number
category: string
type_line: string
mana_cost: string
cmc: number
role: string
reason: string
tcg_price: number | null
image_url: string
owned: boolean
owned_qty: number
scryfall_id: string
is_substitute: boolean
original_name: string | null
substitution_reason: string | null
similarity_score: number | null
}
export interface DeckGenV3Result {
commander: {
name: string
image_url: string
type_line: string
color_identity: string[]
mana_cost: string
oracle_text: string
}
cards: DeckGenV3Card[]
stats: DeckAnalysis
strategy: string
bracket: number
model: string
substitution?: {
owned: number
substituted: number
missing: number
}
}
// ── Simulation ───────────────────────────────────────────────
export interface SimConfig {
deck_names: string[]
num_games: number
threads: number
use_deepseek?: boolean
}
export interface SimStatus {
running: boolean
sim_id: string
progress?: number
games_completed?: number
total_games?: number
}
export interface SimResult {
sim_id: string
decks: SimDeckResult[]
total_games: number
duration_ms: number
timestamp: string
}
export interface SimDeckResult {
name: string
wins: number
losses: number
win_rate: number
avg_turn: number
}
// ── Batch Sim (Lab) ──────────────────────────────────────────
export interface LabDeck {
name: string
source: string
path: string
card_count: number
}
export interface LabStartRequest {
decks: string[]
games: number
threads?: number
use_deepseek?: boolean
deepseek_deck?: string
}
export interface LabStatus {
running: boolean
run_id: string
games_completed: number
total_games: number
current_decks: string[]
error: string | null
}
export interface LabResult {
run_id: string
decks: LabDeckResult[]
total_games: number
timestamp: string
}
export interface LabDeckResult {
name: string
wins: number
games: number
win_rate: number
avg_turns: number
avg_life_remaining: number
}
export interface LabHistoryEntry {
run_id: string
timestamp: string
decks: string[]
total_games: number
}
export interface PreconDeck {
name: string
filename: string
release_date?: string
set_name?: string
installed: boolean
}
// ── Coach ────────────────────────────────────────────────────
export interface CoachStatus {
llmConnected: boolean
llmModel: string | null
llmModels: string[]
embeddingsLoaded: boolean
embeddingCards: number
deckReportsAvailable: number
error: string | null
}
export interface CoachDeck {
deck_id: number
deck_name: string
commander: string
card_count: number
has_report: boolean
report_count: number
last_report_date: string | null
}
export interface CoachGoals {
targetPowerLevel?: number | null
metaFocus?: string | null
budget?: string | null
focusAreas?: string[]
}
export interface CoachMessage {
role: 'user' | 'assistant'
content: string
}
export interface CoachChatResponse {
content: string
model: string
prompt_tokens: number
completion_tokens: number
}
export interface CoachSession {
sessionId: string
deckId: string
timestamp: string
summary: string
suggestedCuts: CoachSuggestedCut[]
suggestedAdds: CoachSuggestedAdd[]
heuristicHints: string[]
manaBaseAdvice: string | null
rawTextExplanation: string
modelUsed: string
promptTokens: number
completionTokens: number
goals: CoachGoals | null
}
export interface CoachSuggestedCut {
cardName: string
reason: string
replacementOptions: string[]
currentImpactScore: number
}
export interface CoachSuggestedAdd {
cardName: string
role: string
reason: string
synergyWith: string[]
estimatedManaValue: number | null
}
export interface CoachSessionSummary {
sessionId: string
deckId: string
timestamp: string
summary: string
cutsCount: number
addsCount: number
}
export interface CoachApplyResult {
cuts: { name: string; status: string }[]
adds: { name: string; scryfall_id?: string; status: string }[]
errors: { name: string; error: string }[]
total_cuts: number
total_adds: number
}
export interface CoachCardLikeResult {
name: string
similarity: number
types: string
mana_value: number
mana_cost: string
text: string
owned_qty: number
image_url: string | null
tcg_price: number | null
}
// ── ML Training ──────────────────────────────────────────────
/** GET /api/ml/status */
export interface MLStatus {
ml_logging_enabled: boolean
training_files: { file: string; decisions: number; size_kb: number }[]
total_decisions: number
total_files: number
}
/** GET /api/ml/data/status */
export interface MLDataStatus {
decisionFiles: { name: string; size: number; decisions: number }[]
totalDecisions: number
datasets: Record<string, { samples: number; features: number; size: number }>
checkpoints: { name: string; size: number; modified: string }[]
evalResults: Record<string, unknown> | null
policyLoaded: boolean
}
/** GET /api/ml/train/status */
export interface MLTrainStatus {
running: boolean
progress: number
total_epochs: number
current_epoch: number
phase: 'idle' | 'starting' | 'building' | 'training' | 'evaluating' | 'done' | 'error'
message: string
metrics: Record<string, unknown> | null
result: {
training: Record<string, unknown>
evaluation: Record<string, unknown> | null
checkpoint: string
device: string
} | null
error: string | null
started_at: string | null
}
/** GET /api/ml/train/ppo/status */
export interface PPOTrainStatus {
running: boolean
iteration: number
total_iterations: number
phase: 'idle' | 'starting' | 'training' | 'done' | 'error'
message: string
metrics: {
avg_reward?: number
win_rate?: number
policy_loss?: number
value_loss?: number
entropy?: number
[key: string]: unknown
} | null
result: Record<string, unknown> | null
error: string | null
}
/** GET /api/ml/tournament/status */
export interface TournamentStatus {
running: boolean
phase: 'idle' | 'starting' | 'running' | 'done' | 'error'
message: string
result: TournamentResults | null
error: string | null
}
export interface TournamentResults {
players: TournamentPlayer[]
total_matches: number
matchups?: Record<string, unknown>
}
export interface TournamentPlayer {
name: string
wins: number
games: number
win_rate: number
}
/** GET /api/ml/model */
export interface MLModelInfo {
loaded: boolean
device?: string
input_dim?: number
num_actions?: number
checkpoint_path?: string
error?: string
torch_available?: boolean
}
// ── DeepSeek ─────────────────────────────────────────────────
export interface DeepSeekStatus {
connected: boolean
model: string | null
base_url: string
}
// ── Commander Search ─────────────────────────────────────────
export interface CommanderSearchResult {
name: string
type_line: string
color_identity: string[]
mana_cost: string
image_url: string
oracle_text: string
scryfall_id: string
in_collection: boolean
}
// ── Perplexity ───────────────────────────────────────────────
export interface PplxStatus {
configured: boolean
}
@@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
@@ -1,28 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
recharts: ['recharts'],
react: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
})
-23
View File
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend-tmp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-5077
View File
File diff suppressed because it is too large. Load diff
-42
View File
@@ -1,42 +0,0 @@
{
"name": "commander-ai-lab-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.0.0",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

Before

Width:  |  Height:  |  Size: 9.3 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

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

Before

Width:  |  Height:  |  Size: 44 KiB

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

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

Before

Width:  |  Height:  |  Size: 8.5 KiB

-54
View File
@@ -1,54 +0,0 @@
import type { GameState } from '../types/game';
import { SEAT_COLOURS } from '../constants';
import PlayerZone from './PlayerZone';
import CardChip from './CardChip';
interface BoardProps {
gameState: GameState;
thinkingSeats: Set<number>;
}
export default function Board({ gameState, thinkingSeats }: BoardProps) {
const { players, active_seat, stack } = gameState;
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
const renderZone = (seat: number, className: string) => {
const player = getPlayer(seat);
if (!player) return <div className={className} />;
return (
<div className={className}>
<PlayerZone
player={player}
isActive={active_seat === seat}
isThinking={thinkingSeats.has(seat)}
seatColour={SEAT_COLOURS[seat]}
/>
</div>
);
};
return (
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
{renderZone(1, 'row-start-1 col-start-1')}
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
<div className="flex flex-wrap gap-0.5">
{stack.map((card, i) => (
<CardChip key={i} card={card} />
))}
</div>
</div>
{renderZone(2, 'row-start-1 col-start-3')}
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
<div className="row-start-2 col-start-1" />
<div className="row-start-2 col-start-3" />
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
{renderZone(0, 'row-start-3 col-start-1')}
{renderZone(3, 'row-start-3 col-start-3')}
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
import type { Card } from '../types/game';
interface CardChipProps {
card: Card;
onClick?: () => void;
}
export default function CardChip({ card, onClick }: CardChipProps) {
return (
<span
onClick={onClick}
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
card.tapped ? 'opacity-55 italic' : ''
}`}
>
{card.name}
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
</span>
);
}
@@ -1,40 +0,0 @@
import type { Player } from '../types/game';
import { SEAT_COLOURS } from '../constants';
interface GameOverOverlayProps {
show: boolean;
winner: Player | null;
reason: string;
players: Player[];
}
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
if (!show) return null;
return (
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
{winner && (
<h2
className="text-4xl font-bold"
style={{ color: SEAT_COLOURS[winner.seat] }}
>
{winner.name} Wins!
</h2>
)}
<p className="text-base text-[#aaa] mt-2">{reason}</p>
<ul className="mt-6 space-y-1">
{players.map((p) => (
<li key={p.seat} className="text-sm">
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
{p.name} — {p.life} life
{p.eliminated && (
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
)}
</li>
))}
</ul>
</div>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import type { Card } from '../types/game';
interface HandCardProps {
card: Card;
index: number;
isSelected: boolean;
onClick: () => void;
}
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
return (
<button
onClick={onClick}
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
isSelected
? 'border border-[#ffdc32] bg-card-selected'
: 'bg-card-hand border border-[#5060a0]'
}`}
>
{card.name}
</button>
);
}
-54
View File
@@ -1,54 +0,0 @@
import { useState } from 'react';
import type { Card } from '../types/game';
import HandCard from './HandCard';
interface MoveBarProps {
hand: Card[];
humanSeat: number;
onPass: () => void;
onConcede: () => void;
onPlayCard: (index: number) => void;
status: string;
}
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const handleCardClick = (index: number) => {
if (selectedIndex === index) {
onPlayCard(index);
setSelectedIndex(null);
} else {
setSelectedIndex(index);
}
};
return (
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
<button
onClick={onPass}
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
>
Pass Priority
</button>
<button
onClick={() => {
if (window.confirm('Are you sure you want to concede?')) onConcede();
}}
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
>
Concede
</button>
{hand.map((card, i) => (
<HandCard
key={i}
card={card}
index={i}
isSelected={selectedIndex === i}
onClick={() => handleCardClick(i)}
/>
))}
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
</div>
);
}
@@ -1,68 +0,0 @@
import { useRef, useEffect, useState } from 'react';
import type { NarrationLine } from '../types/game';
import { SEAT_COLOURS } from '../constants';
interface NarrationPanelProps {
lines: NarrationLine[];
thinkingSeats: Set<number>;
}
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dots, setDots] = useState('.');
// Auto-scroll on new lines
useEffect(() => {
const el = containerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length]);
// Cycling dots for thinking animation
useEffect(() => {
if (thinkingSeats.size === 0) return;
const interval = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
}, 500);
return () => clearInterval(interval);
}, [thinkingSeats.size]);
const displayed = lines.slice(-120);
return (
<div
ref={containerRef}
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
>
{displayed.map((line, i) => {
let colorClass = 'text-[#e8e8e8]';
let style: React.CSSProperties | undefined;
if (line.isSystem) {
colorClass = 'text-[#556]';
} else if (line.seat !== undefined) {
colorClass = '';
style = { color: SEAT_COLOURS[line.seat] };
}
return (
<div
key={i}
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
style={style}
>
{line.text}
</div>
);
})}
{thinkingSeats.size > 0 &&
Array.from(thinkingSeats).map((seat) => (
<div
key={`thinking-${seat}`}
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
>
P{seat} thinking{dots}
</div>
))}
</div>
);
}
-37
View File
@@ -1,37 +0,0 @@
import type { Phase, Player } from '../types/game';
import { PHASES, SEAT_COLOURS } from '../constants';
interface PhaseBarProps {
phase: Phase;
activeSeat: number;
players: Player[];
}
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
const activePlayer = players.find((p) => p.seat === activeSeat);
return (
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
{PHASES.map((p) => (
<button
key={p}
className={`text-[11px] rounded px-2 py-0.5 ${
p === phase
? 'bg-accent-blue text-white'
: 'bg-[#252a42] text-[#888]'
}`}
>
{p}
</button>
))}
{activePlayer && (
<span
className="text-[11px] ml-auto font-bold"
style={{ color: SEAT_COLOURS[activeSeat] }}
>
P{activeSeat}: {activePlayer.name}
</span>
)}
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import type { Player } from '../types/game';
import CardChip from './CardChip';
interface PlayerZoneProps {
player: Player;
isActive: boolean;
isThinking: boolean;
seatColour: string;
}
function lifeColor(life: number): string {
if (life >= 30) return 'text-life-high';
if (life >= 15) return 'text-life-med';
return 'text-life-low';
}
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
return (
<div
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
isThinking ? 'animate-pulse' : ''
} ${player.eliminated ? 'opacity-40' : ''}`}
style={isActive ? { borderColor: seatColour } : undefined}
>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-[#666]">
P{player.seat}: {player.name}
</span>
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
{player.life}
</span>
</div>
<div className="flex flex-wrap gap-0.5">
{player.battlefield.map((card, i) => (
<CardChip key={i} card={card} />
))}
</div>
</div>
);
}
-9
View File
@@ -1,9 +0,0 @@
import type { Phase } from './types/game';
export const SEAT_COLOURS = ['#4696ff', '#ff6450', '#50d278', '#dcaa32'] as const;
export const PHASES: Phase[] = [
'untap', 'upkeep', 'draw', 'main1',
'begin_combat', 'declare_attackers', 'declare_blockers', 'combat_damage',
'main2', 'end_step', 'cleanup',
];
-132
View File
@@ -1,132 +0,0 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
import { gameReducer, initialState } from '../store/gameReducer';
import type { WsIncoming, NarrationLine } from '../types/game';
export function useGameSocket(gameId: string) {
const [state, dispatch] = useReducer(gameReducer, initialState);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => {
const url = `ws://${window.location.host}/ws/game/${gameId}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
dispatch({ type: 'SET_STATUS', status: 'Connected' });
};
ws.onmessage = (event) => {
const msg: WsIncoming = JSON.parse(event.data);
switch (msg.type) {
case 'state':
dispatch({ type: 'SET_STATE', payload: msg.payload });
break;
case 'hand':
dispatch({ type: 'SET_HAND', cards: msg.cards });
break;
case 'event': {
const line: NarrationLine = {
text: msg.text,
seat: msg.seat,
isSystem: false,
timestamp: Date.now(),
};
dispatch({ type: 'ADD_NARRATION', line });
break;
}
case 'phase': {
const line: NarrationLine = {
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
isSystem: true,
timestamp: Date.now(),
};
dispatch({ type: 'ADD_NARRATION', line });
break;
}
case 'thinking':
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
break;
case 'game_over':
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
break;
case 'pong':
break;
case 'error':
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
break;
}
};
ws.onclose = () => {
if (!intentionalClose.current) {
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
reconnectTimeout.current = setTimeout(() => {
connect();
}, 3000);
}
};
ws.onerror = () => {
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
};
}, [gameId]);
// Connect on mount
useEffect(() => {
intentionalClose.current = false;
connect();
return () => {
intentionalClose.current = true;
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
}, [connect]);
// Heartbeat ping every 15s
useEffect(() => {
heartbeatInterval.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 15000);
return () => {
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
};
}, []);
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
}
}, []);
const sendConcede = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'concede' }));
}
}, []);
return {
gameState: state.gameState,
narration: state.narration,
thinking: state.thinkingSeats,
hand: state.hand,
gameOver: state.gameOver,
status: state.status,
sendMove,
sendConcede,
};
}
-163
View File
@@ -1,163 +0,0 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
export interface Seat {
seat: number;
name: string;
ready: boolean;
isAi: boolean;
}
export interface RoomState {
roomId: string;
roomName: string;
host: string;
seats: (Seat | null)[];
}
export interface ChatMessage {
role: 'PLAYER' | 'AI' | 'SYSTEM';
name: string;
text: string;
timestamp: number;
}
type LobbyAction =
| { type: 'SET_ROOM'; payload: RoomState }
| { type: 'ADD_CHAT'; message: ChatMessage }
| { type: 'SET_CHAT_HISTORY'; messages: ChatMessage[] }
| { type: 'LAUNCH'; gameId: string };
interface LobbyState {
roomState: RoomState | null;
chat: ChatMessage[];
launchedGameId: string | null;
}
const initialState: LobbyState = {
roomState: null,
chat: [],
launchedGameId: null,
};
function lobbyReducer(state: LobbyState, action: LobbyAction): LobbyState {
switch (action.type) {
case 'SET_ROOM':
return { ...state, roomState: action.payload };
case 'ADD_CHAT':
return { ...state, chat: [...state.chat, action.message].slice(-50) };
case 'SET_CHAT_HISTORY':
return { ...state, chat: action.messages.slice(-50) };
case 'LAUNCH':
return { ...state, launchedGameId: action.gameId };
default:
return state;
}
}
export function useLobbySock(roomId: string, playerName: string) {
const [state, dispatch] = useReducer(lobbyReducer, initialState);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const intentionalClose = useRef(false);
const connect = useCallback(() => {
const url = `ws://${window.location.host}/ws/lobby/${roomId}?name=${encodeURIComponent(playerName)}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'lobby_state':
dispatch({ type: 'SET_ROOM', payload: msg.payload });
break;
case 'chat':
dispatch({
type: 'ADD_CHAT',
message: {
role: msg.role ?? 'PLAYER',
name: msg.name ?? '',
text: msg.text,
timestamp: Date.now(),
},
});
break;
case 'chat_history':
dispatch({
type: 'SET_CHAT_HISTORY',
messages: (msg.messages ?? []).map((m: { role?: string; name?: string; text: string }) => ({
role: m.role ?? 'SYSTEM',
name: m.name ?? '',
text: m.text,
timestamp: Date.now(),
})),
});
break;
case 'launch':
dispatch({ type: 'LAUNCH', gameId: msg.game_id });
break;
case 'pong':
break;
}
};
ws.onclose = () => {
if (!intentionalClose.current) {
reconnectTimeout.current = setTimeout(() => connect(), 3000);
}
};
}, [roomId, playerName]);
useEffect(() => {
intentionalClose.current = false;
connect();
return () => {
intentionalClose.current = true;
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
}, [connect]);
// Heartbeat ping every 15s
useEffect(() => {
heartbeatInterval.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 15000);
return () => {
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
};
}, []);
const sendReady = useCallback((seat: number) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ready', seat }));
}
}, []);
const sendChat = useCallback((text: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'chat', text }));
}
}, []);
const sendLaunch = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'launch' }));
}
}, []);
return {
roomState: state.roomState,
chat: state.chat,
launchedGameId: state.launchedGameId,
sendReady,
sendChat,
sendLaunch,
};
}
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
-53
View File
@@ -1,53 +0,0 @@
import { useParams } from 'react-router-dom';
import { useGameSocket } from '../hooks/useGameSocket';
import PhaseBar from '../components/PhaseBar';
import Board from '../components/Board';
import NarrationPanel from '../components/NarrationPanel';
import MoveBar from '../components/MoveBar';
import GameOverOverlay from '../components/GameOverOverlay';
export default function GamePage() {
const { gameId } = useParams<{ gameId: string }>();
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
useGameSocket(gameId!);
if (!gameState) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
Connecting…
</div>
);
}
const winnerPlayer = gameOver
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
: null;
return (
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
<PhaseBar
phase={gameState.current_phase}
activeSeat={gameState.active_seat}
players={gameState.players}
/>
<div className="flex flex-1 overflow-hidden">
<Board gameState={gameState} thinkingSeats={thinking} />
<NarrationPanel lines={narration} thinkingSeats={thinking} />
</div>
<MoveBar
hand={hand}
humanSeat={0}
onPass={() => sendMove('pass')}
onConcede={sendConcede}
onPlayCard={(index) => sendMove('play_card', { index })}
status={status}
/>
<GameOverOverlay
show={gameOver !== null}
winner={winnerPlayer}
reason={gameOver?.reason ?? ''}
players={gameState.players}
/>
</div>
);
}
-141
View File
@@ -1,141 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
interface RoomSummary {
roomId: string;
name: string;
host: string;
playerCount: number;
}
export default function LobbyPage() {
const navigate = useNavigate();
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(true);
// Create room form state
const [roomName, setRoomName] = useState('');
const [hostName, setHostName] = useState('');
const [aiSlots, setAiSlots] = useState(0);
const [password, setPassword] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
fetch('/api/v1/lobby/rooms')
.then((res) => res.json())
.then((data) => setRooms(data))
.catch(() => setRooms([]))
.finally(() => setLoading(false));
}, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true);
try {
const res = await fetch('/api/v1/lobby/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: roomName,
host: hostName,
ai_slots: aiSlots,
password: password || undefined,
}),
});
const data = await res.json();
navigate(`/room/${data.roomId}?name=${encodeURIComponent(hostName)}`);
} catch {
setCreating(false);
}
};
return (
<div className="min-h-screen bg-bg-base text-white p-6">
<h1 className="text-3xl font-bold mb-6">Commander AI Lab — Lobby</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
{/* Room List */}
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
<h2 className="text-lg font-semibold mb-3">Open Rooms</h2>
{loading ? (
<p className="text-text-muted text-sm">Loading...</p>
) : rooms.length === 0 ? (
<p className="text-text-muted text-sm">No rooms available. Create one!</p>
) : (
<ul className="space-y-2">
{rooms.map((room) => (
<li key={room.roomId}>
<button
onClick={() => navigate(`/room/${room.roomId}`)}
className="w-full text-left bg-bg-panel border border-border-zone rounded px-3 py-2 hover:border-accent-blue transition-colors"
>
<span className="font-medium">{room.name}</span>
<span className="text-text-muted text-sm ml-2">
Host: {room.host} · {room.playerCount}/4 players
</span>
</button>
</li>
))}
</ul>
)}
</div>
{/* Create Room Form */}
<div className="bg-bg-zone rounded-lg border border-border-zone p-4">
<h2 className="text-lg font-semibold mb-3">Create Room</h2>
<form onSubmit={handleCreate} className="space-y-3">
<div>
<label className="block text-sm text-text-muted mb-1">Room Name</label>
<input
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
required
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Your Name</label>
<input
type="text"
value={hostName}
onChange={(e) => setHostName(e.target.value)}
required
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">AI Players</label>
<select
value={aiSlots}
onChange={(e) => setAiSlots(Number(e.target.value))}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value={0}>0 AI players</option>
<option value={1}>1 AI player</option>
<option value={2}>2 AI players</option>
<option value={3}>3 AI players</option>
</select>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Password (optional)</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<button
type="submit"
disabled={creating}
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50"
>
{creating ? 'Creating...' : 'Create Room'}
</button>
</form>
</div>
</div>
</div>
);
}
-220
View File
@@ -1,220 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { useLobbySock } from '../hooks/useLobbySock';
import type { ChatMessage } from '../hooks/useLobbySock';
const ROLE_COLORS: Record<string, string> = {
PLAYER: 'text-accent-blue',
AI: 'text-accent-purple',
SYSTEM: 'text-text-muted',
};
function ChatBadge({ role }: { role: string }) {
return (
<span className={`text-[10px] font-bold mr-1 ${ROLE_COLORS[role] ?? 'text-text-muted'}`}>
[{role}]
</span>
);
}
export default function RoomPage() {
const { roomId } = useParams<{ roomId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const isSpectator = searchParams.get('spectate') === '1';
const nameFromUrl = searchParams.get('name') ?? '';
const [playerName, setPlayerName] = useState(nameFromUrl);
const [nameConfirmed, setNameConfirmed] = useState(!!nameFromUrl);
const [chatInput, setChatInput] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);
const { roomState, chat, launchedGameId, sendReady, sendChat, sendLaunch } =
useLobbySock(roomId!, nameConfirmed ? playerName : '');
// Redirect when game launches
useEffect(() => {
if (launchedGameId) {
navigate(`/game/${launchedGameId}`);
}
}, [launchedGameId, navigate]);
// Auto-scroll chat
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [chat.length]);
// Determine own seat
const mySeat = roomState?.seats.findIndex(
(s) => s && !s.isAi && s.name === playerName,
) ?? -1;
const isHost = mySeat === 0;
if (!nameConfirmed) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
<div className="bg-bg-zone border border-border-zone rounded-lg p-6 w-80">
<h2 className="text-lg font-semibold mb-3">Enter your name</h2>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && playerName.trim()) setNameConfirmed(true);
}}
className="w-full bg-bg-panel border border-border-zone rounded px-3 py-1.5 text-sm text-white mb-3 focus:outline-none focus:border-accent-blue"
autoFocus
/>
<button
onClick={() => playerName.trim() && setNameConfirmed(true)}
className="w-full bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
>
Join Room
</button>
</div>
</div>
);
}
if (!roomState) {
return (
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
Connecting to room...
</div>
);
}
const handleSendChat = () => {
if (chatInput.trim()) {
sendChat(chatInput.trim());
setChatInput('');
}
};
return (
<div className="flex flex-col h-screen bg-bg-base text-white">
{/* Header */}
<div className="bg-bg-bar border-b border-border-sep px-4 py-2 flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">{roomState.roomName}</h1>
<span className="text-text-muted text-sm">Host: {roomState.host}</span>
</div>
{isSpectator && (
<span className="text-text-muted text-xs bg-bg-panel px-2 py-1 rounded">
SPECTATING
</span>
)}
</div>
<div className="flex flex-1 overflow-hidden">
{/* Seat Grid */}
<div className="flex-1 p-4">
<div className="grid grid-cols-2 gap-3 max-w-lg">
{[0, 1, 2, 3].map((seatIdx) => {
const seat = roomState.seats[seatIdx];
return (
<div
key={seatIdx}
className="bg-bg-zone border border-border-zone rounded-lg p-4 min-h-[100px] flex flex-col items-center justify-center"
>
{seat ? (
<>
<span className="text-sm font-medium">
{seat.isAi ? '🤖 AI' : seat.name}
</span>
<span className="text-xs text-text-muted mt-1">
Seat {seatIdx}
</span>
{!seat.isAi && (
<span
className={`text-xs mt-1 font-bold ${
seat.ready ? 'text-life-high' : 'text-life-med'
}`}
>
{seat.ready ? 'READY' : 'Not Ready'}
</span>
)}
</>
) : (
<>
<span className="text-text-muted text-sm">Waiting...</span>
<span className="text-xs text-text-muted mt-1">
Seat {seatIdx}
</span>
</>
)}
</div>
);
})}
</div>
{/* Action buttons */}
{!isSpectator && (
<div className="mt-4 flex gap-3">
{mySeat >= 0 && (
<button
onClick={() => sendReady(mySeat)}
className="bg-btn-primary border border-border-btn-primary text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110"
>
Toggle Ready
</button>
)}
{isHost && (
<button
onClick={async () => {
try {
const res = await fetch(`/api/v1/lobby/rooms/${roomId}/launch`, {
method: 'POST',
});
const data = await res.json();
if (data.gameId) {
navigate(`/game/${data.gameId}`);
}
} catch {
// launch via WS fallback
sendLaunch();
}
}}
className="bg-life-high border border-life-high text-white rounded px-4 py-2 text-sm font-bold hover:brightness-110"
>
Launch Game
</button>
)}
</div>
)}
</div>
{/* Chat Panel */}
<div className="w-[280px] bg-bg-panel border-l border-border-sep flex flex-col">
<div className="px-3 py-2 border-b border-border-sep text-sm font-semibold">
Chat
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{chat.map((msg: ChatMessage, i: number) => (
<div key={i} className="text-[11px] leading-[1.4] break-words">
<ChatBadge role={msg.role} />
{msg.name && <span className="font-medium mr-1">{msg.name}:</span>}
<span className="text-text-body">{msg.text}</span>
</div>
))}
<div ref={chatEndRef} />
</div>
{!isSpectator && (
<div className="border-t border-border-sep p-2">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSendChat();
}}
placeholder="Type a message..."
className="w-full bg-bg-base border border-border-zone rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-accent-blue"
/>
</div>
)}
</div>
</div>
</div>
);
}
-391
View File
@@ -1,391 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { SEAT_COLOURS } from '../constants';
import type { SimDeck, SimResult } from '../types/simulator';
const SEAT_LABELS = ['Seat 1 (You)', 'Seat 2', 'Seat 3', 'Seat 4'];
type SimStatus = 'idle' | 'running' | 'complete' | 'error';
export default function SimulatorPage() {
// Deck list from API
const [decks, setDecks] = useState<SimDeck[]>([]);
const [loadingDecks, setLoadingDecks] = useState(true);
// Slot selections (4 slots, null = empty)
const [slots, setSlots] = useState<(number | null)[]>([null, null, null, null]);
// Config
const [numGames, setNumGames] = useState(10);
const [engineType, setEngineType] = useState('heuristic');
const [recordLogs, setRecordLogs] = useState(true);
// Sim state
const [simId, setSimId] = useState<string | null>(null);
const [status, setStatus] = useState<SimStatus>('idle');
const [completed, setCompleted] = useState(0);
const [total, setTotal] = useState(0);
const [result, setResult] = useState<SimResult | null>(null);
const [games, setGames] = useState<unknown[]>([]);
const [error, setError] = useState<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Fetch decks on mount
useEffect(() => {
fetch('/api/decks')
.then((res) => res.json())
.then((data) => {
const list = (data.decks || []).map((d: Record<string, unknown>) => ({
id: d.id as number,
name: d.name as string,
commander_name: (d.commander_name || '') as string,
color_identity: (d.color_identity || []) as string[],
}));
setDecks(list);
})
.catch(() => setDecks([]))
.finally(() => setLoadingDecks(false));
}, []);
// Polling
useEffect(() => {
if (status !== 'running' || !simId) return;
pollRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/sim/status?simId=${simId}`);
const data = await res.json();
setCompleted(data.completed ?? 0);
setTotal(data.total ?? 0);
if (data.status === 'complete') {
setStatus('complete');
// Fetch results
const rres = await fetch(`/api/sim/result?simId=${simId}`);
const rdata = await rres.json();
setResult(rdata.summary ?? null);
setGames(rdata.games ?? []);
} else if (data.status === 'error') {
setStatus('error');
setError(data.error || 'Simulation failed');
}
} catch {
// keep polling
}
}, 1000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [status, simId]);
const selectedCount = slots.filter((s) => s !== null).length;
const canRun = selectedCount >= 2 && status !== 'running';
const handleRun = async () => {
const deckIds = slots.filter((s): s is number => s !== null);
setStatus('running');
setCompleted(0);
setTotal(numGames);
setResult(null);
setGames([]);
setError(null);
try {
const res = await fetch('/api/sim/run-n-player', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
deckIds,
numGames,
recordLogs,
engineType,
}),
});
const data = await res.json();
if (!res.ok) {
setStatus('error');
setError(data.error || 'Failed to start simulation');
return;
}
setSimId(data.simId);
setTotal(data.total);
} catch {
setStatus('error');
setError('Network error');
}
};
const setSlot = (idx: number, value: number | null) => {
setSlots((prev) => {
const next = [...prev];
next[idx] = value;
return next;
});
};
const getDeckById = (id: number) => decks.find((d) => d.id === id);
return (
<div className="min-h-screen bg-bg-base text-white p-6">
<div className="max-w-5xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Batch Simulator</h1>
<p className="text-text-muted text-sm mb-6">
Run N-player Monte Carlo simulations with your decks.
</p>
{/* Deck Selection */}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Deck Slots</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{SEAT_LABELS.map((label, idx) => {
const selected = slots[idx];
const deck = selected !== null ? getDeckById(selected) : null;
return (
<div
key={idx}
className="bg-bg-zone rounded-md p-4"
style={{
border: `2px solid ${selected !== null ? SEAT_COLOURS[idx] : '#3a4060'}`,
boxShadow: selected !== null ? `0 0 8px ${SEAT_COLOURS[idx]}40` : 'none',
}}
>
<div
className="text-sm font-medium mb-2"
style={{ color: SEAT_COLOURS[idx] }}
>
{label}
</div>
<select
value={selected ?? ''}
onChange={(e) => setSlot(idx, e.target.value ? Number(e.target.value) : null)}
className="w-full bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="">— Empty —</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
{deck && (
<div className="mt-2 text-xs text-text-muted">
{deck.commander_name && (
<div className="truncate">{deck.commander_name}</div>
)}
{deck.color_identity.length > 0 && (
<div>{deck.color_identity.join(' ')}</div>
)}
</div>
)}
</div>
);
})}
</div>
{loadingDecks && (
<p className="text-text-muted text-sm mt-2">Loading decks...</p>
)}
</div>
{/* Config Row */}
<div className="flex flex-wrap items-end gap-4 mb-6 bg-bg-zone rounded-md border border-border-zone p-4">
<div>
<label className="block text-sm text-text-muted mb-1">Games</label>
<input
type="number"
min={1}
max={1000}
value={numGames}
onChange={(e) => setNumGames(Math.max(1, Math.min(1000, Number(e.target.value))))}
className="w-24 bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
/>
</div>
<div>
<label className="block text-sm text-text-muted mb-1">Engine</label>
<select
value={engineType}
onChange={(e) => setEngineType(e.target.value)}
className="bg-bg-panel border border-border-zone rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-accent-blue"
>
<option value="heuristic">Heuristic AI</option>
<option value="deepseek">DeepSeek AI</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="recordLogs"
checked={recordLogs}
onChange={(e) => setRecordLogs(e.target.checked)}
className="rounded"
/>
<label htmlFor="recordLogs" className="text-sm text-text-muted">
Record Logs
</label>
</div>
<button
onClick={handleRun}
disabled={!canRun}
className="bg-accent-blue text-white rounded px-4 py-2 text-sm font-medium hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed"
>
{status === 'running' ? (
<span className="flex items-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Running...
</span>
) : (
'Run Simulation'
)}
</button>
</div>
{/* Progress */}
{status === 'running' && (
<div className="mb-6">
<div className="flex justify-between text-sm text-text-muted mb-1">
<span>Progress</span>
<span>
{completed} / {total} games
</span>
</div>
<div className="w-full bg-bg-panel rounded-full h-3 overflow-hidden">
<div
className="h-full bg-accent-blue rounded-full transition-all duration-300"
style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}
/>
</div>
</div>
)}
{/* Error */}
{status === 'error' && error && (
<div className="mb-6 bg-red-900/30 border border-red-700 rounded-md p-3 text-sm text-red-300">
{error}
</div>
)}
{/* Results */}
{status === 'complete' && result && (
<div className="space-y-6">
<div className="flex items-center gap-4 text-sm text-text-muted">
<span>{result.totalGames} games</span>
<span>{result.playerCount} players</span>
<span>{result.elapsedSeconds}s elapsed</span>
</div>
{/* Win Rate Bar Chart */}
<div className="bg-bg-zone rounded-md border border-border-zone p-4">
<h3 className="text-sm font-semibold mb-3">Win Rates</h3>
<div className="space-y-2">
{result.players.map((p) => (
<div key={p.seat} className="flex items-center gap-3">
<div className="w-28 text-sm truncate" style={{ color: SEAT_COLOURS[p.seat] }}>
{p.deckName}
</div>
<div className="flex-1 bg-bg-panel rounded-full h-5 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${p.winRate}%`,
backgroundColor: SEAT_COLOURS[p.seat],
minWidth: p.winRate > 0 ? '2px' : '0',
}}
/>
</div>
<div className="w-16 text-right text-sm font-mono">
{p.winRate}%
</div>
</div>
))}
</div>
</div>
{/* Stats Table */}
<div className="bg-bg-zone rounded-md border border-border-zone overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-zone text-text-muted">
<th className="text-left px-3 py-2">Seat</th>
<th className="text-left px-3 py-2">Deck</th>
<th className="text-right px-3 py-2">Wins</th>
<th className="text-right px-3 py-2">Win%</th>
<th className="text-right px-3 py-2">Avg Turns</th>
<th className="text-right px-3 py-2">Avg Dmg</th>
<th className="text-right px-3 py-2">Avg Spells</th>
<th className="text-right px-3 py-2">Avg Creatures</th>
<th className="text-right px-3 py-2">Avg Removal</th>
</tr>
</thead>
<tbody>
{result.players.map((p) => {
const isWinner = result.players.every((o) => o.wins <= p.wins);
return (
<tr
key={p.seat}
className={`border-b border-border-zone ${isWinner ? 'font-semibold' : ''}`}
style={{
backgroundColor: `${SEAT_COLOURS[p.seat]}${isWinner ? '20' : '0a'}`,
}}
>
<td className="px-3 py-2" style={{ color: SEAT_COLOURS[p.seat] }}>
P{p.seat + 1}
</td>
<td className="px-3 py-2">{p.deckName}</td>
<td className="text-right px-3 py-2">{p.wins}</td>
<td className="text-right px-3 py-2">{p.winRate}%</td>
<td className="text-right px-3 py-2">{p.avgTurns}</td>
<td className="text-right px-3 py-2">{p.avgDamageDealt}</td>
<td className="text-right px-3 py-2">{p.avgSpellsCast}</td>
<td className="text-right px-3 py-2">{p.avgCreaturesPlayed}</td>
<td className="text-right px-3 py-2">{p.avgRemovalUsed}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Per-Game Logs (collapsible) */}
{games.length > 0 && (
<div className="bg-bg-zone rounded-md border border-border-zone">
<button
onClick={() => setShowLogs(!showLogs)}
className="w-full text-left px-4 py-3 text-sm font-medium flex justify-between items-center hover:bg-bg-panel transition-colors"
>
<span>Per-Game Results ({games.length} games)</span>
<span className="text-text-muted">{showLogs ? '▲' : '▼'}</span>
</button>
{showLogs && (
<div className="px-4 pb-3 max-h-96 overflow-y-auto">
<div className="space-y-1">
{games.map((g: unknown, i: number) => {
const game = g as Record<string, unknown>;
return (
<div
key={i}
className="flex items-center gap-3 text-xs text-text-muted py-1 border-b border-border-zone"
>
<span className="w-12 font-mono">#{game.gameNumber as number}</span>
<span>
Winner:{' '}
<span
style={{
color: SEAT_COLOURS[(game.winner ?? game.winningSeat ?? 0) as number],
}}
>
P{((game.winner ?? game.winningSeat ?? 0) as number) + 1}
</span>
</span>
<span>{game.turns as number} turns</span>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
);
}
-61
View File
@@ -1,61 +0,0 @@
import type { GameState, Card, NarrationLine } from '../types/game';
export type GameAction =
| { type: 'SET_STATE'; payload: GameState }
| { type: 'SET_HAND'; cards: Card[] }
| { type: 'ADD_NARRATION'; line: NarrationLine }
| { type: 'SET_THINKING'; seat: number; active: boolean }
| { type: 'GAME_OVER'; winner: number; reason: string }
| { type: 'SET_STATUS'; status: string };
export interface ReducerState {
gameState: GameState | null;
hand: Card[];
narration: NarrationLine[];
thinkingSeats: Set<number>;
gameOver: { winner: number; reason: string } | null;
status: string;
}
export const initialState: ReducerState = {
gameState: null,
hand: [],
narration: [],
thinkingSeats: new Set(),
gameOver: null,
status: '',
};
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
switch (action.type) {
case 'SET_STATE':
return { ...state, gameState: action.payload };
case 'SET_HAND':
return { ...state, hand: action.cards };
case 'ADD_NARRATION': {
const narration = [...state.narration, action.line];
return { ...state, narration: narration.slice(-120) };
}
case 'SET_THINKING': {
const thinkingSeats = new Set(state.thinkingSeats);
if (action.active) {
thinkingSeats.add(action.seat);
} else {
thinkingSeats.delete(action.seat);
}
return { ...state, thinkingSeats };
}
case 'GAME_OVER':
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
case 'SET_STATUS':
return { ...state, status: action.status };
default:
return state;
}
}
-1
View File
@@ -1 +0,0 @@
import '@testing-library/jest-dom';
-55
View File
@@ -1,55 +0,0 @@
export type Phase =
| 'untap' | 'upkeep' | 'draw' | 'main1'
| 'begin_combat' | 'declare_attackers' | 'declare_blockers' | 'combat_damage'
| 'main2' | 'end_step' | 'cleanup';
export interface Card {
name: string;
type: string;
tapped: boolean;
is_commander: boolean;
oracle: string;
cmc: number;
}
export interface Player {
seat: number;
name: string;
life: number;
eliminated: boolean;
hand: Card[];
battlefield: Card[];
graveyard: Card[];
exile: Card[];
command_zone: Card[];
hand_count: number;
}
export interface GameState {
game_id: string;
turn: number;
current_phase: Phase;
active_seat: number;
players: Player[];
stack: Card[];
game_over: boolean;
winner: Player | null;
}
export type WsIncoming =
| { type: 'state'; payload: GameState }
| { type: 'event'; text: string; seat?: number }
| { type: 'phase'; phase: Phase; active_seat: number }
| { type: 'thinking'; seat: number; active: boolean }
| { type: 'elimination'; seat: number }
| { type: 'game_over'; winner: number; reason: string }
| { type: 'hand'; cards: Card[] }
| { type: 'pong' }
| { type: 'error'; message: string };
export interface NarrationLine {
text: string;
seat?: number;
isSystem: boolean;
timestamp: number;
}
-28
View File
@@ -1,28 +0,0 @@
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
@@ -1,82 +0,0 @@
/** @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
@@ -1,28 +0,0 @@
{
"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
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-21
View File
@@ -1,21 +0,0 @@
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
@@ -1,12 +0,0 @@
/// <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',
},
})
+10 -16
View File
@@ -22,15 +22,10 @@ except ImportError:
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
import sys; sys.exit(1)
from routes.shared import (
CFG,
setup_logging,
init_collection_db,
download_precon_database,
load_commander_meta,
PRECON_INDEX,
COMMANDER_META,
)
from models.state import CFG, COMMANDER_META, load_commander_meta
from services.database import init_collection_db
from services.logging import setup_logging
from services.precon_service import PRECON_INDEX, download_precon_database
from routes.collection import router as collection_router
from routes.deckbuilder import router as deckbuilder_router
from routes.precon import router as precon_router
@@ -80,15 +75,14 @@ async def health_check():
@app.on_event("startup")
async def _on_startup():
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
import routes.shared as _shared
# Ensure precon_dir is resolved even when started via uvicorn directly
if not CFG.precon_dir:
CFG.precon_dir = _resolve_precon_dir()
_shared.init_collection_db()
if not _shared.PRECON_INDEX:
_shared.download_precon_database()
if not _shared.COMMANDER_META:
_shared.load_commander_meta()
init_collection_db()
if not PRECON_INDEX:
download_precon_database()
if not COMMANDER_META:
load_commander_meta()
# ── Static UI ──────────────────────────────────────────────────
_legacy_ui_dir = Path(__file__).parent / "ui"
@@ -288,7 +282,7 @@ def main():
CFG.ximilar_api_key = args.ximilar_key
CFG.pplx_api_key = args.pplx_key
from routes.shared import get_java17
from services.forge_runner import get_java17
j17 = get_java17()
log.info("Commander AI Lab — API Server v3.0.0")
+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)
log.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
log.info("─" * 43)
logger.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
logger.info("─" * 43)
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
log.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
log.info(f"{'─' * 43}")
log.info(f" {'TOTAL':<23} {total:>8}")
logger.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
logger.info(f"{'─' * 43}")
logger.info(f" {'TOTAL':<23} {total:>8}")
return dist
+93 -1
View File
@@ -155,15 +155,25 @@ class StateDimensions:
2 players = 28 global features
+ turn_number = 1
Total global = 29
Zone embedding boundary (architectural decision — Phase 5 follow-up):
Zone dims are **always** 768 per zone slot, zero-padded when a zone
is empty (no cards). The fixed 6144-dim zone block is the contract
between ForgeEpisodeGenerator (upstream) and the policy network
(downstream). All downstream consumers (PolicyNetwork, PPOTrainer,
dataset builder) depend on this shape. If a future change adds
attention pooling or variable-length zone representations, the
boundary must be updated here *and* in StateEncoder._encode_zones.
"""
card_embedding_dim: int = 768 # From mtg-embeddings
zone_pool_dim: int = 768 # Mean-pooled per zone
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
# Zones per player: hand, battlefield, graveyard, command_zone
zones_per_player: int = 4
num_players: int = 2 # 1v1
# Zone vectors: 4 zones × 2 players × 768 = 6144
# This is a fixed-size block; empty zones produce zero vectors.
total_zone_dim: int = 4 * 2 * 768 # 6144
# Global scalar features
@@ -256,3 +266,85 @@ class TrainingConfig:
log_every: int = 100 # Log every N batches
TRAINING_CONFIG = TrainingConfig()
# ══════════════════════════════════════════════════════════
# Distillation Loop Defaults (Phase 4)
# ══════════════════════════════════════════════════════════
@dataclass
class DistillationDefaults:
"""Default hyperparameters for the closed-loop distillation pipeline.
These are consumed by ml.training.distillation_loop.DistillationConfig
when no explicit override is provided.
Default weights: Forge-only (forge_weight=1.0, ppo_weight=0.0).
The baseline trains exclusively on Forge simulation data. Use
``MIXED_MODE_PRESETS`` below for blended Forge+PPO configurations.
"""
max_iterations: int = 10
convergence_window: int = 3
convergence_threshold: float = 0.01
# PPO self-play per generation
ppo_iterations: int = 50
ppo_episodes_per_iter: int = 64
ppo_lr: float = 3e-4
ppo_clip_epsilon: float = 0.2
ppo_entropy_coeff: float = 0.01
ppo_eval_episodes: int = 100
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
forge_weight: float = 1.0
ppo_weight: float = 0.0
min_reward_threshold: float = 0.0
# Quality gate thresholds
min_forge_accuracy: float = 0.35
max_accuracy_drop: float = 0.03
min_ppo_win_rate: float = 0.30
# Retry behaviour on gate failure
max_retries_per_generation: int = 2
retry_lr_factor: float = 0.5
retry_entropy_boost: float = 0.005
# Output paths
results_dir: str = "data/results"
history_dir: str = "data/results/distillation-history"
DISTILLATION_DEFAULTS = DistillationDefaults()
# ══════════════════════════════════════════════════════════
# Mixed-Mode Presets (Phase 5 follow-up)
# ══════════════════════════════════════════════════════════
# Pre-defined Forge/PPO weight combinations for common scenarios.
# Select a preset by name when starting a distillation run to
# override the Forge-only defaults without specifying raw weights.
#
# "forge_only" — baseline: 100% Forge data, ignore PPO
# "forge_90_10" — light PPO blending: 90% Forge, 10% PPO
# "forge_80_20" — heavier PPO blending: 80% Forge, 20% PPO
MIXED_MODE_PRESETS: Dict[str, Dict[str, float]] = {
"forge_only": {"forge_weight": 1.0, "ppo_weight": 0.0},
"forge_90_10": {"forge_weight": 1.0, "ppo_weight": 0.11}, # ~10% PPO
"forge_80_20": {"forge_weight": 1.0, "ppo_weight": 0.25}, # ~20% PPO
}
def get_preset_weights(preset_name: str) -> Dict[str, float]:
"""Look up a mixed-mode preset by name.
Returns a dict with ``forge_weight`` and ``ppo_weight`` keys.
Raises ``KeyError`` if the preset name is unknown.
"""
if preset_name not in MIXED_MODE_PRESETS:
raise KeyError(
f"Unknown preset '{preset_name}'. "
f"Available: {list(MIXED_MODE_PRESETS.keys())}"
)
return dict(MIXED_MODE_PRESETS[preset_name])
+292 -53
View File
@@ -1,9 +1,17 @@
"""
Commander AI Lab — Training Dataset Builder
════════════════════════════════════════════
Reads ML decision JSONL files, encodes states, labels actions,
and produces a training-ready dataset saved as NPZ.
Phase 2: Source Awareness (Issue #66)
- Scans both Forge sim files (ml-decisions-sim-*.jsonl) and
PPO self-play files (ml-decisions-ppo-*.jsonl)
- Configurable source_weights for sampling ratios
- min_reward_threshold filter for PPO data quality
- Each sample tagged with source for downstream analysis
Usage:
python -m ml.data.dataset_builder --results-dir results/ --output ml/models/dataset.npz
@@ -13,6 +21,7 @@ Output NPZ structure:
game_ids: object (N,) — game ID for each sample
outcomes: object (N,) — game outcome per sample
playstyles: object (N,) — deck archetype per sample
sources: object (N,) — data source ("forge" or "ppo")
"""
import argparse
@@ -20,8 +29,9 @@ import json
import logging
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Tuple
from typing import Dict, List, Optional, Tuple
import numpy as np
@@ -40,22 +50,167 @@ logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.dataset")
# ---------------------------------------------------------------------------
# Configuration (Phase 2)
# ---------------------------------------------------------------------------
@dataclass
class DatasetConfig:
"""Configuration for dataset building with source awareness."""
results_dir: str = "results"
embeddings_dir: Optional[str] = None
max_samples: Optional[int] = None
# Source weights control sampling ratios between Forge and PPO data.
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
source_weights: Dict[str, float] = field(
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
)
# Minimum reward threshold for PPO data — only include decisions
# from games where the episode return exceeds this value.
# Set to None to disable filtering.
min_reward_threshold: Optional[float] = 0.0
# ---------------------------------------------------------------------------
# Source-aware file discovery (Phase 2 — Task 1)
# ---------------------------------------------------------------------------
def _discover_jsonl_files(results_dir: str) -> Dict[str, List[Path]]:
"""
Scan results directory for both Forge sim and PPO decision files.
Returns:
Dict mapping source name to list of JSONL file paths.
Keys: "forge" for ml-decisions-sim-*.jsonl
"ppo" for ml-decisions-ppo-*.jsonl
"""
results_path = Path(results_dir)
sources = {}
# Forge batch sim files
forge_files = sorted(results_path.glob("ml-decisions-sim-*.jsonl"))
if forge_files:
sources["forge"] = forge_files
# PPO self-play files (from Phase 1 DecisionExporter)
ppo_files = sorted(results_path.glob("ml-decisions-ppo-*.jsonl"))
if ppo_files:
sources["ppo"] = ppo_files
# Also pick up legacy files that don't have sim/ppo prefix
legacy_files = sorted(
f for f in results_path.glob("ml-decisions-*.jsonl")
if f not in set(forge_files + ppo_files)
)
if legacy_files:
sources.setdefault("forge", []).extend(legacy_files)
return sources
# ---------------------------------------------------------------------------
# PPO reward filter (Phase 2 — Task 3)
# ---------------------------------------------------------------------------
def _passes_reward_filter(
decision: dict,
source: str,
min_reward_threshold: Optional[float],
) -> bool:
"""
Filter PPO decisions by reward threshold.
Forge data always passes. PPO data must meet the threshold.
"""
if source != "ppo":
return True
if min_reward_threshold is None:
return True
episode_return = decision.get("episode_return", 0.0)
return episode_return >= min_reward_threshold
# ---------------------------------------------------------------------------
# Source-aware sampling (Phase 2 — Task 2)
# ---------------------------------------------------------------------------
def _apply_source_weights(
samples_by_source: Dict[str, List[dict]],
source_weights: Dict[str, float],
rng: np.random.Generator,
) -> List[dict]:
"""
Apply source weights to control the mixing ratio.
A weight of 1.0 keeps all samples from that source.
A weight of 0.5 randomly keeps ~50% of samples.
A weight > 1.0 oversamples (with replacement).
"""
combined = []
for source, samples in samples_by_source.items():
weight = source_weights.get(source, 1.0)
if weight <= 0:
logger.info(" Skipping %d %s samples (weight=0)", len(samples), source)
continue
if weight >= 1.0 and weight == 1.0:
combined.extend(samples)
elif weight < 1.0:
# Subsample
n_keep = max(1, int(len(samples) * weight))
indices = rng.choice(len(samples), size=n_keep, replace=False)
combined.extend(samples[i] for i in indices)
logger.info(
" Subsampled %s: %d → %d (weight=%.2f)",
source, len(samples), n_keep, weight,
)
else:
# Oversample (weight > 1.0)
n_target = int(len(samples) * weight)
indices = rng.choice(len(samples), size=n_target, replace=True)
combined.extend(samples[i] for i in indices)
logger.info(
" Oversampled %s: %d → %d (weight=%.2f)",
source, len(samples), n_target, weight,
)
return combined
# ---------------------------------------------------------------------------
# Main dataset builder (updated for Phase 2)
# ---------------------------------------------------------------------------
def build_dataset(
results_dir: str,
embeddings_dir: str = None,
max_samples: int = None,
source_weights: Dict[str, float] = None,
min_reward_threshold: float = 0.0,
) -> Dict[str, np.ndarray]:
"""
Build complete training dataset from all ML decision files.
Phase 2 additions:
- Scans both Forge and PPO JSONL files
- Applies source_weights for configurable mixing
- Filters PPO data by min_reward_threshold
- Tags each sample with its source
Args:
results_dir: Path to results/ directory containing ml-decisions-*.jsonl
results_dir: Path to results/ directory containing JSONL files
embeddings_dir: Path to embeddings/ directory (default: auto-detect)
max_samples: Cap total samples (None = unlimited)
source_weights: Dict of source → sampling weight (default: forge=1.0, ppo=0.5)
min_reward_threshold: Min episode_return for PPO data (default: 0.0)
Returns:
Dict with keys: states, labels, game_ids, outcomes, playstyles
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
"""
if source_weights is None:
source_weights = {"forge": 1.0, "ppo": 0.0}
# Load card embeddings
card_index = CardEmbeddingIndex(embeddings_dir)
if not card_index.load():
@@ -70,10 +225,9 @@ def build_dataset(
encoder = StateEncoder(card_index)
# Find all JSONL files
results_path = Path(results_dir)
jsonl_files = sorted(results_path.glob("ml-decisions-*.jsonl"))
if not jsonl_files:
# --- Phase 2 Task 1: Discover files from both sources ---
source_files = _discover_jsonl_files(results_dir)
if not source_files:
logger.error("No ML decision files found in %s", results_dir)
logger.error("Run batch simulations with --ml-log or enable ML logging in the UI first.")
raise RuntimeError(
@@ -81,60 +235,114 @@ def build_dataset(
"Run batch simulations with ML logging enabled first."
)
logger.info("Found %d ML decision files", len(jsonl_files))
total_files = sum(len(files) for files in source_files.values())
logger.info(
"Found %d ML decision files across %d sources: %s",
total_files,
len(source_files),
{src: len(files) for src, files in source_files.items()},
)
# --- Read and filter all decisions, grouped by source ---
rng = np.random.default_rng(42)
samples_by_source: Dict[str, List[dict]] = {}
total_skipped = 0
total_filtered = 0
for source, jsonl_files in source_files.items():
source_samples = []
for jsonl_file in jsonl_files:
logger.info("Processing %s [%s] ...", jsonl_file.name, source)
file_count = 0
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
# --- Phase 2 Task 3: PPO reward filter ---
if not _passes_reward_filter(
decision, source, min_reward_threshold
):
total_filtered += 1
continue
# --- Phase 2 Task 4: Tag with source ---
decision["_source"] = source
source_samples.append(decision)
file_count += 1
except Exception as e:
total_skipped += 1
if total_skipped <= 10:
logger.warning(
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
)
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
if source_samples:
samples_by_source[source] = source_samples
if not samples_by_source:
logger.error("No valid samples found.")
return {}
# Log per-source counts before weighting
for src, samples in samples_by_source.items():
logger.info("Source '%s': %d raw samples", src, len(samples))
if total_filtered > 0:
logger.info("PPO reward filter removed %d samples (threshold=%.2f)",
total_filtered, min_reward_threshold or 0.0)
# --- Phase 2 Task 2: Apply source weights ---
combined_decisions = _apply_source_weights(
samples_by_source, source_weights, rng
)
# Apply max_samples cap after weighting
if max_samples and len(combined_decisions) > max_samples:
rng.shuffle(combined_decisions)
combined_decisions = combined_decisions[:max_samples]
logger.info("Capped to %d samples", max_samples)
# --- Encode all decisions ---
all_states = []
all_labels = []
all_game_ids = []
all_outcomes = []
all_playstyles = []
total_skipped = 0
all_sources = []
encode_errors = 0
for jsonl_file in jsonl_files:
logger.info("Processing %s ...", jsonl_file.name)
file_count = 0
for decision in combined_decisions:
try:
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
action_idx = label_action(decision)
game_id = decision.get("game_id", "unknown")
outcome = decision.get("game_outcome", "unknown")
source = decision.get("_source", "forge")
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f):
if max_samples and len(all_states) >= max_samples:
break
all_states.append(state_vec)
all_labels.append(action_idx)
all_game_ids.append(game_id)
all_outcomes.append(outcome)
all_playstyles.append(playstyle)
all_sources.append(source)
line = line.strip()
if not line:
continue
try:
decision = json.loads(line)
# Encode state
playstyle = decision.get("archetype", "midrange")
state_vec = encoder.encode(decision, playstyle)
# Label action
action_idx = label_action(decision)
# Metadata
game_id = decision.get("game_id", f"unknown-{line_num}")
outcome = decision.get("game_outcome", "unknown")
all_states.append(state_vec)
all_labels.append(action_idx)
all_game_ids.append(game_id)
all_outcomes.append(outcome)
all_playstyles.append(playstyle)
file_count += 1
except Exception as e:
total_skipped += 1
if total_skipped <= 10:
logger.warning(
"Skipping %s line %d: %s", jsonl_file.name, line_num, e
)
logger.info(" → %d samples from %s", file_count, jsonl_file.name)
except Exception as e:
encode_errors += 1
if encode_errors <= 5:
logger.warning("Encode error: %s", e)
if not all_states:
logger.error("No valid samples found.")
logger.error("No valid samples after encoding.")
return {}
# Convert to arrays
@@ -143,10 +351,11 @@ def build_dataset(
game_ids = np.array(all_game_ids, dtype=object)
outcomes = np.array(all_outcomes, dtype=object)
playstyles = np.array(all_playstyles, dtype=object)
sources = np.array(all_sources, dtype=object)
logger.info(
"Dataset built: %d samples, %d features, %d actions, %d skipped",
len(states), states.shape[1], NUM_ACTIONS, total_skipped,
"Dataset built: %d samples, %d features, %d actions, %d skipped, %d encode errors",
len(states), states.shape[1], NUM_ACTIONS, total_skipped, encode_errors,
)
# Print label distribution
@@ -166,12 +375,21 @@ def build_dataset(
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
logger.info(f" {str(s):<23} {c:>8}")
# --- Phase 2 Task 4: Print source distribution ---
unique_sources, source_counts = np.unique(sources, return_counts=True)
logger.info(f"\n{'Source':<25} {'Count':>8} {'%':>7}")
logger.info("─" * 42)
for s, c in sorted(zip(unique_sources, source_counts), key=lambda x: -x[1]):
pct = 100.0 * c / len(sources)
logger.info(f" {str(s):<23} {c:>8} {pct:>6.1f}%")
return {
"states": states,
"labels": labels,
"game_ids": game_ids,
"outcomes": outcomes,
"playstyles": playstyles,
"sources": sources,
}
@@ -240,7 +458,6 @@ def split_dataset(
len(train["states"]), len(val["states"]), len(test["states"]),
split_by_game,
)
return train, val, test
@@ -279,14 +496,36 @@ def main():
"--no-split", action="store_true",
help="Don't split into train/val/test (save single file)",
)
# Phase 2 CLI arguments
parser.add_argument(
"--forge-weight", type=float, default=1.0,
help="Sampling weight for Forge sim data (default: 1.0)",
)
parser.add_argument(
"--ppo-weight", type=float, default=0.0,
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
)
parser.add_argument(
"--min-reward", type=float, default=0.0,
help="Minimum episode_return for PPO data (default: 0.0)",
)
args = parser.parse_args()
source_weights = {
"forge": args.forge_weight,
"ppo": args.ppo_weight,
}
# Build dataset
dataset = build_dataset(
results_dir=args.results_dir,
embeddings_dir=args.embeddings_dir,
max_samples=args.max_samples,
source_weights=source_weights,
min_reward_threshold=args.min_reward,
)
if not dataset:
logger.info("\nNo data to save. Run simulations with --ml-log first.")
sys.exit(1)
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more