Moved all 15 /api/ml/* endpoints, 4 state dicts, and 3 background
pipeline functions from lab_api.py into routes/ml.py. Wired via
app.include_router(ml_router) in lab_api.py.
Added _send_to_graveyard() helper that checks card.is_commander
and routes to command_zone (resetting tapped/turn_played) or
graveyard. Applied to all 4 death paths: removal, board wipe,
blocker death, and attacker death in combat.
Second pass in _play_spells() checks command_zone for castable
commanders, applies commander_tax, taps lands, places on
battlefield, and increments tax by 2 for subsequent casts.
- Add commander_names param to run(), run_n(), _create_state()
- Commander card flagged is_commander=True and placed in
player.command_zone instead of shuffled into library/hand
- Only first matching card is extracted (handles singleton rule)
- Backward compatible: commander_names defaults to None
Adds command_zone (list[Card]) and commander_tax (int, +2 per cast)
to the base Player model so the headless engine can track commander
zone and tax natively without requiring CommanderPlayer wrappers.
Both were missing from [project.dependencies] in pyproject.toml and
requirements.txt. httpx is used in web/routers/art.py, and websockets
is needed for the FastAPI WebSocket endpoints in ws.py and lobby.py.
Closes#37
- Remove plaintext Ximilar and Perplexity API keys from start-lab.bat
- Remove hardcoded key defaults from lab_api.py, lab_api_backup.py, lab_api_new.py
- start-lab.bat now reads from environment variables or .env file
- Add .env.example with placeholder values
- .env already in .gitignore (confirmed)
- Git history purged of all key values via git-filter-repo
Closes#26
Task 1: Move BUILTIN_COMMANDERS + load_commander_meta() to shared.py
Fix import_routes.py to use _shared.COMMANDER_META (module ref)
Task 2: Rewrite precon.py to use _shared.PRECON_INDEX (module ref)
Task 3: Add @app.on_event('startup') safety net for uvicorn --reload
Adds an @app.on_event('startup') hook that imports routes._wire.
This means lab_api.py needs zero changes — the routers self-register
when the app boots. Drop this file into the repo root and it works.
Alternatively, add to lab_api.py after app creation:
from routes import _autoload # noqa
Called via `import routes._wire` at the bottom of lab_api.py (after all
symbols are defined). Registers every shared object into routes.shared
and mounts all five APIRouters onto the FastAPI app.
Batch 6/6 — final wiring step.
Two fixes:
1. _parse_text_decklist(): replace fragile startswith() section-header
detection with case-insensitive regex so [Commander], [Main], [Deck]
etc. are always recognised regardless of capitalisation.
2. add_deck_card endpoint: when a card is added with is_commander=1,
also update the deck-level commander_name + commander_scryfall_id
so the portrait and top-bar label stay in sync.
Two fixes:
1. _parse_text_decklist(): replace fragile startswith() section header
detection with proper case-insensitive regex so [Commander], [Main],
[Deck] etc. are always recognised regardless of capitalisation.
2. add_deck_card endpoint: when a card is added with is_commander=1,
also update the deck-level commander_name and commander_scryfall_id
fields so the portrait and top-bar label stay in sync without
requiring a full reload.
- run_n() accepts 2-4 decks; run() wraps for backward compat
- _select_attack_target() heuristic for multiplayer combat targeting
- N-player elimination order tracking with finish positions
- Removal scans all opponents for scariest creature
- ML snapshot captures all N players
- Log entries use per-seat boards dict with legacy boardA/boardB for 2-player
Two places lost the commander flag:
1. _load_deck_cards_by_name() .dck fallback — used a flat in_cards
boolean for [Main], [Deck], and [Commander] sections so it couldn't
tell which section a card came from. Now tracks current_section and
sets is_commander=1 for cards in [Commander].
2. parse_dck_file() — stored section='Commander' in the card dict but
the frontend filters on c.is_commander, not c.section. Now includes
is_commander: 1/0 alongside the existing section field.
Replace all print() calls with structured logging using the Python
logging module. This provides:
- Named loggers per subsystem: commander_ai_lab.api, .batch, .sim,
.coach, .deckgen, .collection, .scanner, .ml, .cache, .pplx
- Dual output: console (StreamHandler) + rotating file (RotatingFileHandler)
at logs/commander-ai-lab.log (25 MB x 5 backups = 125 MB max)
- Configurable via env vars: CAL_LOG_DIR, CAL_LOG_MAX_MB, CAL_LOG_BACKUP_COUNT
- --verbose / -v flag for DEBUG-level output
- overnight-run.py: separate rotating log at logs/overnight-run.log (10 MB x 5)
- Sub-module loggers in ml/, scanner/, deck_sources/, src/ all propagate
to the root commander_ai_lab logger
- Stripped redundant [TAG] prefixes from messages (logger name carries context)
- Format: '2026-03-13 14:00:00 INFO [commander_ai_lab.batch] message'
Files changed: lab_api.py, overnight-run.py, and 11 sub-module .py files
176 total print() calls converted (101 in lab_api.py, 76 in sub-modules)
GameResult.to_dict() now returns both legacy keys (winner, playerA, playerB)
that the UI expects AND new N-player keys (winningSeat, playerResults) for
the Java BatchResult schema.
When collection_entries has multiple rows with the same scryfall_id
(e.g. Counterspell imported from two different sets), the LEFT JOIN
on deck_cards produced duplicate rows — making it appear like 2 copies
were added or removed.
Fixed all 6 deck_cards-to-collection_entries JOINs to use a
GROUP BY scryfall_id subquery, guaranteeing 1:1 cardinality.
- Added _fill_basic_lands() post-processing for V1 and V3 generators
- Strips LLM-provided basics, then distributes evenly across commander colors
- Always hits exactly 100 cards regardless of LLM output
- Frontend group counts now sum card.count instead of entry count
- Cards with count > 1 show '5x Forest' style prefix
- Colorless commanders get Wastes
Previously the coach required running simulations first to generate a
deck-reports/*.json file. Now if no simulation report is found, the
endpoint builds a lightweight DeckReport on the fly from the deck_cards
table — pulling card names, types, CMC, and color identity directly from
the DB. The LLM can still analyze composition and give advice, just
without simulation-derived performance metrics (impactScore, etc.).
Also removes the '[no report]' tag from the dropdown since all decks
can now be coached.
The decks table has 'commander_name' not 'commander'. The SQL query
was throwing OperationalError which the frontend silently caught,
resulting in an empty dropdown.
Root cause: Perplexity deck generation hit the 8192 max_tokens limit,
truncating the JSON response mid-string at card ~70-80. json.loads()
fails with 'Unterminated string' and the fallback also couldn't recover.
Fixes:
- Bump DECK_GEN_MAX_TOKENS from 8192 to 16384 (primary fix)
- Add _repair_truncated_json() to PerplexityClient — if token limit is
still hit, salvages all complete card objects from the truncated JSON
instead of returning a 422 error
- Add OUTPUT EFFICIENCY instructions to system prompt to reduce per-card
verbosity (shorter reasons, fewer synergy_with entries)
- Add 'truncated' flag to response so UI can warn if deck is incomplete
- Log warning when recovered deck has <90 cards
Backend returns [{deck_id, deck_name, commander, has_report, ...}] directly,
not {decks: [...]}. Frontend was reading data.decks which was always undefined.
Now properly unwraps the array, shows deck name + commander, slugifies name
for coach session deck_id, and marks decks without reports.